From 2ac6ef6608694a6a6fecf0615274a69ca5ca5f8d Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 16 May 2024 01:29:54 +0200 Subject: [PATCH] Adds api --- .env.example | 8 + apps/api/.env.example | 41 + apps/api/.eslintrc.json | 18 + apps/api/Dockerfile | 60 + apps/api/jest.config.ts | 11 + apps/api/package.json | 20 + apps/api/project.json | 111 + apps/api/scripts/migrate-mongo.ts | 4 + apps/api/scripts/script.ts | 23 + apps/api/scripts/src/demo.ts | 267 + apps/api/scripts/src/galachain.ts | 83 + apps/api/scripts/src/invoices.ts | 16 + apps/api/scripts/src/ipfs.ts | 29 + apps/api/scripts/src/metamask.ts | 136 + apps/api/scripts/src/preview.ts | 177 + apps/api/scripts/src/safe.ts | 31 + apps/api/scripts/src/sdk.ts | 12 + apps/api/scripts/src/time.ts | 14 + apps/api/scripts/src/utils/index.ts | 98 + apps/api/scripts/src/veLiquidity.ts | 48 + apps/api/scripts/src/veRewards.ts | 48 + apps/api/scripts/src/veTransfer.ts | 19 + apps/api/scripts/upgradeContractsToLatest.ts | 20 + apps/api/sonar-project.properties | 6 + .../app/config/migrate-mongo-create-only.json | 4 + apps/api/src/app/config/migrate-mongo.ts | 13 + apps/api/src/app/config/secrets.ts | 121 + .../app/connection-profiles/cpp-curator.json | 37 + .../app/connection-profiles/cpp-partner.json | 41 + .../app/connection-profiles/cpp-users.json | 59 + apps/api/src/app/contracts/abis/BAL.json | 315 + apps/api/src/app/contracts/abis/BPT.json | 349 + apps/api/src/app/contracts/abis/BPTGauge.json | 395 + .../abis/BalancerGaugeController.json | 26 + .../app/contracts/abis/BalancerMinter.json | 73 + .../src/app/contracts/abis/BalancerVault.json | 121 + .../api/src/app/contracts/abis/Launchpad.json | 183 + .../src/app/contracts/abis/LensReward.json | 93 + .../contracts/abis/LimitedSupplyToken.json | 298 + .../app/contracts/abis/NonFungibleToken.json | 744 ++ .../app/contracts/abis/RewardDistributor.json | 670 ++ .../src/app/contracts/abis/RewardFaucet.json | 324 + .../contracts/abis/SmartWalletWhitelist.json | 229 + apps/api/src/app/contracts/abis/THX.json | 297 + .../contracts/abis/THXPaymentSplitter.json | 184 + .../src/app/contracts/abis/THXRegistry.json | 180 + .../src/app/contracts/abis/THX_ERC1155.json | 679 ++ .../api/src/app/contracts/abis/TestToken.json | 304 + apps/api/src/app/contracts/abis/USDC.json | 297 + .../contracts/abis/UnlimitedSupplyToken.json | 586 ++ .../src/app/contracts/abis/VotingEscrow.json | 1028 +++ apps/api/src/app/contracts/bytecodes/BPT.json | 3 + .../bytecodes/LimitedSupplyToken.json | 3 + .../contracts/bytecodes/NonFungibleToken.json | 3 + .../app/contracts/bytecodes/THX_ERC1155.json | 3 + .../bytecodes/UnlimitedSupplyToken.json | 3 + apps/api/src/app/contracts/index.ts | 120 + .../app/controllers/account/account.router.ts | 68 + .../controllers/account/delete.controller.ts | 9 + .../account/disconnect/post.controller.ts | 13 + .../discord/get.by-discord-id.controller.ts | 15 + .../account/discord/get.controller.ts | 19 + .../app/controllers/account/get.controller.ts | 39 + .../controllers/account/patch.controller.ts | 9 + .../account/twitter/search/post.controller.ts | 17 + .../account/twitter/tweet/post.controller.ts | 15 + .../user/by/username/post.controller.ts | 15 + .../account/twitter/user/post.controller.ts | 15 + .../account/wallet/confirm/post.controller.ts | 28 + .../account/wallet/list.controller.ts | 15 + .../account/wallet/post.controller.ts | 26 + .../account/wallet/wallets.router.ts | 23 + .../account/wallet/wallets.test.ts | 86 + .../app/controllers/brands/brands.router.ts | 20 + .../app/controllers/brands/get.controller.ts | 9 + .../app/controllers/brands/put.controller.ts | 34 + .../app/controllers/client/client.router.ts | 19 + .../app/controllers/client/list.controller.ts | 16 + .../controllers/client/patch.controller.ts | 15 + .../app/controllers/client/post.controller.ts | 34 + .../app/controllers/coupons/coupons.router.ts | 22 + .../controllers/coupons/delete.controller.ts | 30 + .../controllers/coupons/list.controller.ts | 32 + .../src/app/controllers/data/data.router.ts | 35 + .../src/app/controllers/earn/earn.router.ts | 10 + .../earn/metrics/list.controller.ts | 16 + .../earn/prices/list.controller.ts | 9 + .../controllers/erc1155/delete.controller.ts | 16 + .../app/controllers/erc1155/erc1155.router.ts | 99 + .../app/controllers/erc1155/erc1155.test.ts | 89 + .../app/controllers/erc1155/get.controller.ts | 21 + .../erc1155/import/erc1155-import.test.ts | 123 + .../erc1155/import/post.controller.ts | 104 + .../erc1155/import/preview/post.controller.ts | 22 + .../controllers/erc1155/list.controller.ts | 13 + .../erc1155/metadata/delete.controller.ts | 13 + .../erc1155/metadata/get.controller.ts | 13 + .../erc1155/metadata/list.controller.ts | 25 + .../erc1155/metadata/patch.controller.ts | 34 + .../erc1155/metadata/post.controller.ts | 52 + .../controllers/erc1155/patch.controller.ts | 17 + .../controllers/erc1155/post.controller.ts | 37 + .../erc1155/token/get.controller.ts | 34 + .../erc1155/token/list.controller.ts | 30 + .../erc1155/transfer/post.controller.ts | 42 + .../erc20/allowance/allowance.router.ts | 16 + .../erc20/allowance/get.controller.ts | 27 + .../erc20/allowance/post.controller.ts | 40 + .../erc20/balance/balance.router.ts | 9 + .../erc20/balance/get.controller.ts | 23 + .../controllers/erc20/delete.controller.ts | 12 + .../src/app/controllers/erc20/erc20.router.ts | 72 + .../src/app/controllers/erc20/erc20.test.ts | 129 + .../app/controllers/erc20/get.controller.ts | 37 + .../app/controllers/erc20/list.controller.ts | 11 + .../app/controllers/erc20/patch.controller.ts | 15 + .../app/controllers/erc20/post.controller.ts | 39 + .../erc20/preview/get.controller.ts | 20 + .../erc20/preview/preview.router.ts | 9 + .../controllers/erc20/token/get.controller.ts | 31 + .../erc20/token/list.controller.ts | 22 + .../erc20/token/post.controller.ts | 21 + .../controllers/erc20/token/token.router.ts | 0 .../erc20/transfer/erc20-transfer.test.ts | 104 + .../erc20/transfer/post.controller.ts | 32 + .../erc20/transfer/transfer.router.ts | 9 + .../controllers/erc721/delete.controller.ts | 16 + .../app/controllers/erc721/erc721.router.ts | 107 + .../src/app/controllers/erc721/erc721.test.ts | 93 + .../app/controllers/erc721/get.controller.ts | 28 + .../erc721/import/erc721-import.test.ts | 102 + .../erc721/import/post.controller.ts | 86 + .../erc721/import/preview/post.controller.ts | 11 + .../app/controllers/erc721/list.controller.ts | 11 + .../erc721/metadata/delete.controller.ts | 13 + .../erc721/metadata/erc721-metadata.test.ts | 129 + .../erc721/metadata/get.controller.ts | 13 + .../erc721/metadata/images/post.controller.ts | 102 + .../erc721/metadata/list.controller.ts | 24 + .../erc721/metadata/patch.controller.ts | 45 + .../erc721/metadata/post.controller.ts | 39 + .../controllers/erc721/patch.controller.ts | 19 + .../app/controllers/erc721/post.controller.ts | 46 + .../erc721/token/get.controller.ts | 42 + .../erc721/token/list.controller.ts | 30 + .../erc721/transfer/erc721-transfer.test.ts | 191 + .../erc721/transfer/post.controller.ts | 35 + .../app/controllers/events/events.router.ts | 9 + .../app/controllers/events/post.controller.ts | 24 + .../app/controllers/health/health.router.ts | 8 + .../app/controllers/health/list.controller.ts | 146 + .../controllers/identity/get.controller.ts | 21 + .../controllers/identity/identity.router.ts | 23 + .../controllers/identity/patch.controller.ts | 24 + .../controllers/identity/post.controller.ts | 21 + apps/api/src/app/controllers/index.ts | 67 + .../app/controllers/jobs/get.controller.ts | 12 + .../src/app/controllers/jobs/jobs.router.ts | 16 + .../leaderboards/get.controller.ts | 25 + .../leaderboards/leaderboards.router.ts | 11 + .../leaderboards/list.controller.ts | 67 + .../controllers/liquidity/liquidity.router.ts | 12 + .../controllers/liquidity/post.controller.ts | 17 + .../liquidity/stake/post.controller.ts | 26 + .../metadata/erc1155/get.controller.ts | 24 + .../controllers/metadata/get.controller.ts | 23 + .../controllers/metadata/metadata.router.ts | 15 + .../participants/get.controller.ts | 38 + .../participants/participants.router.ts | 21 + .../participants/patch.controller.ts | 31 + .../pools/analytics/analytics.router.ts | 18 + .../pools/analytics/list.controller.ts | 17 + .../analytics/metrics/list.controller.ts | 20 + .../pools/analytics/metrics/metrics.router.ts | 15 + .../collaborators/collaborators.router.ts | 30 + .../pools/collaborators/delete.controller.ts | 21 + .../pools/collaborators/patch.controller.ts | 26 + .../pools/collaborators/post.controller.ts | 14 + .../controllers/pools/delete.controller.ts | 18 + .../pools/erc1155/balance/balance.router.ts | 9 + .../pools/erc1155/balance/get.controller.ts | 23 + .../pools/erc1155/erc1155.router.ts | 8 + .../pools/erc20/allowance/allowance.router.ts | 11 + .../pools/erc20/allowance/get.controller.ts | 31 + .../pools/erc20/allowance/post.controller.ts | 36 + .../pools/erc20/balance/balance.router.ts | 15 + .../pools/erc20/balance/get.controller.ts | 27 + .../controllers/pools/erc20/erc20.router.ts | 10 + .../controllers/pools/events/events.router.ts | 15 + .../pools/events/list.controller.ts | 29 + .../app/controllers/pools/get.controller.ts | 65 + .../pools/guilds/delete.controller.ts | 12 + .../controllers/pools/guilds/guilds.router.ts | 39 + .../pools/guilds/list.controller.ts | 13 + .../pools/guilds/patch.controller.ts | 21 + .../pools/guilds/post.controller.ts | 27 + .../pools/identities/delete.controller.ts | 17 + .../pools/identities/get.controller.ts | 19 + .../pools/identities/identities.router.ts | 23 + .../pools/identities/post.controller.ts | 20 + .../pools/integrations/integrations.router.ts | 8 + .../twitter/queries/delete.controller.ts | 17 + .../twitter/queries/list.controller.ts | 13 + .../twitter/queries/patch.controller.ts | 22 + .../twitter/queries/post.controller.ts | 31 + .../integrations/twitter/twitter.router.ts | 39 + .../pools/invoices/invoices.router.ts | 9 + .../pools/invoices/list.controller.ts | 15 + .../app/controllers/pools/list.controller.ts | 11 + .../pools/participants/list.controller.ts | 21 + .../pools/participants/participants.router.ts | 23 + .../pools/participants/patch.controller.ts | 29 + .../app/controllers/pools/patch.controller.ts | 54 + .../pools/payments/payments.router.ts | 15 + .../pools/payments/post.controller.ts | 52 + .../src/app/controllers/pools/pools.router.ts | 77 + .../src/app/controllers/pools/pools.test.ts | 88 + .../app/controllers/pools/post.controller.ts | 24 + .../pools/quests/delete.controller.ts | 26 + .../pools/quests/entries/entries.router.ts | 15 + .../pools/quests/entries/list.controller.ts | 26 + .../pools/quests/list.controller.ts | 60 + .../pools/quests/patch.controller.ts | 19 + .../pools/quests/post.controller.ts | 59 + .../controllers/pools/quests/quests.router.ts | 49 + .../pools/rewards/coin-rewards.test.ts | 110 + .../pools/rewards/delete.controller.ts | 26 + .../rewards/discord-role-rewards.router.ts | 46 + .../pools/rewards/list.controller.ts | 70 + .../pools/rewards/nft-rewards.test.ts | 134 + .../pools/rewards/patch.controller.ts | 19 + .../pools/rewards/payments/list.controller.ts | 30 + .../pools/rewards/payments/payments.router.ts | 15 + .../pools/rewards/post.controller.ts | 57 + .../pools/rewards/rewards.router.ts | 49 + .../pools/wallets/list.controller.ts | 12 + .../pools/wallets/wallets.router.ts | 15 + .../qr-codes/collect/post.controller.ts | 64 + .../controllers/qr-codes/delete.controller.ts | 24 + .../controllers/qr-codes/get.controller.ts | 29 + .../controllers/qr-codes/list.controller.ts | 44 + .../controllers/qr-codes/post.controller.ts | 26 + .../controllers/qr-codes/qr-codes.router.ts | 33 + .../app/controllers/qr-codes/qr-codes.test.ts | 192 + .../qr-codes/redirect/get.controller.ts | 35 + .../quests/custom/custom.router.ts | 16 + .../controllers/quests/custom/custom.test.ts | 84 + .../quests/custom/entries/post.controller.ts | 38 + .../controllers/quests/daily/daily.router.ts | 16 + .../quests/daily/entries/post.controller.ts | 40 + .../quests/gitcoin/entries/post.controller.ts | 50 + .../quests/gitcoin/gitcoin.router.ts | 16 + .../app/controllers/quests/list.controller.ts | 38 + .../app/controllers/quests/quests.router.ts | 24 + .../quests/recent/list.controller.ts | 99 + .../quests/social/entries/post.controller.ts | 68 + .../quests/social/social.router.ts | 10 + .../quests/web3/entries/post.controller.ts | 59 + .../controllers/quests/web3/web3.router.ts | 10 + .../quests/webhook/entries/post.controller.ts | 42 + .../quests/webhook/webhook.router.ts | 16 + .../controllers/rewards/list.controller.ts | 22 + .../rewards/payments/list.controller.ts | 12 + .../rewards/payments/post.controller.ts | 47 + .../app/controllers/rewards/rewards.router.ts | 18 + .../controllers/token/cs/get.controller.ts | 8 + .../src/app/controllers/token/token.router.ts | 11 + .../controllers/token/ts/get.controller.ts | 8 + .../transactions/list.controller.ts | 21 + .../app/controllers/upload/put.controller.ts | 10 + .../app/controllers/upload/upload.router.ts | 9 + .../controllers/ve/claim/post.controller.ts | 14 + .../controllers/ve/deposit/post.controller.ts | 35 + .../ve/increase/post.controller.ts | 56 + .../src/app/controllers/ve/list.controller.ts | 34 + apps/api/src/app/controllers/ve/ve.router.ts | 20 + apps/api/src/app/controllers/ve/ve.test.ts | 332 + .../ve/withdraw/post.controller.ts | 34 + .../webhook/daily/daily-quest-webhook.test.ts | 95 + .../webhook/daily/post.controller.ts | 34 + .../webhook/gateway/post.controller.ts | 68 + .../milestones/claim/post.controller.ts | 49 + .../app/controllers/webhook/webhook.router.ts | 16 + .../controllers/webhooks/delete.controller.ts | 12 + .../controllers/webhooks/list.controller.ts | 25 + .../controllers/webhooks/patch.controller.ts | 13 + .../controllers/webhooks/post.controller.ts | 17 + .../controllers/webhooks/webhooks.router.ts | 39 + .../app/controllers/widget/get.controller.ts | 31 + .../controllers/widget/js/get.controller.ts | 566 ++ .../app/controllers/widget/widget.router.ts | 29 + .../app/controllers/widgets/get.controller.ts | 12 + .../controllers/widgets/list.controller.ts | 9 + .../controllers/widgets/patch.controller.ts | 33 + .../app/controllers/widgets/widgets.router.ts | 25 + .../app/controllers/widgets/widgets.test.ts | 73 + apps/api/src/app/events/ClientReady.ts | 11 + apps/api/src/app/events/GuildCreate.ts | 17 + apps/api/src/app/events/GuildDelete.ts | 15 + apps/api/src/app/events/InteractionCreated.ts | 75 + apps/api/src/app/events/MessageCreate.ts | 59 + apps/api/src/app/events/MessageReactionAdd.ts | 33 + apps/api/src/app/events/commands/error.ts | 20 + apps/api/src/app/events/commands/index.ts | 11 + apps/api/src/app/events/commands/thx.ts | 64 + apps/api/src/app/events/commands/thx/buy.ts | 18 + apps/api/src/app/events/commands/thx/index.ts | 4 + apps/api/src/app/events/commands/thx/info.ts | 85 + .../api/src/app/events/commands/thx/points.ts | 141 + apps/api/src/app/events/commands/thx/quest.ts | 21 + apps/api/src/app/events/components/index.ts | 2 + .../app/events/components/selectMenuQuests.ts | 75 + .../events/components/selectMenuRewards.ts | 43 + .../src/app/events/handlers/button/quest.ts | 28 + .../src/app/events/handlers/button/reward.ts | 60 + apps/api/src/app/events/handlers/index.ts | 3 + .../src/app/events/handlers/select/quest.ts | 138 + apps/api/src/app/events/index.ts | 16 + apps/api/src/app/index.ts | 53 + apps/api/src/app/jobs/createTwitterQuests.ts | 99 + .../src/app/jobs/sendPoolAnalyticsReport.ts | 110 + apps/api/src/app/jobs/updateCampaignRanks.ts | 100 + .../src/app/jobs/updateParticipantRanks.ts | 20 + .../src/app/jobs/updatePendingTransactions.ts | 56 + apps/api/src/app/middlewares/assertAccount.ts | 21 + apps/api/src/app/middlewares/assertPayment.ts | 26 + .../src/app/middlewares/assertPoolAccess.ts | 25 + .../src/app/middlewares/assertQuestAccess.ts | 19 + .../src/app/middlewares/assertRequestInput.ts | 13 + apps/api/src/app/middlewares/assertUUID.ts | 11 + apps/api/src/app/middlewares/assertWallet.ts | 19 + apps/api/src/app/middlewares/checkJwt.ts | 20 + apps/api/src/app/middlewares/corsHandler.ts | 29 + apps/api/src/app/middlewares/errorLogger.ts | 11 + .../src/app/middlewares/errorNormalizer.ts | 11 + apps/api/src/app/middlewares/errorOutput.ts | 29 + apps/api/src/app/middlewares/index.ts | 12 + apps/api/src/app/middlewares/morgan.ts | 16 + .../src/app/middlewares/notFoundHandler.ts | 6 + .../20240321144151-galachain-pkey.js | 24 + .../20240329123915-quest-metadata.js | 29 + .../20240430124026-message-query.js | 84 + apps/api/src/app/models/Brand.ts | 15 + apps/api/src/app/models/Client.ts | 42 + apps/api/src/app/models/Collaborator.ts | 18 + apps/api/src/app/models/CouponCode.ts | 17 + apps/api/src/app/models/DiscordGuild.ts | 22 + apps/api/src/app/models/DiscordMessage.ts | 18 + apps/api/src/app/models/DiscordReaction.ts | 19 + apps/api/src/app/models/DiscordUser.ts | 22 + apps/api/src/app/models/ERC1155.ts | 30 + apps/api/src/app/models/ERC1155Metadata.ts | 20 + apps/api/src/app/models/ERC1155Token.ts | 22 + apps/api/src/app/models/ERC20.ts | 38 + apps/api/src/app/models/ERC20Token.ts | 16 + apps/api/src/app/models/ERC20Transfer.ts | 19 + apps/api/src/app/models/ERC721.ts | 31 + apps/api/src/app/models/ERC721Metadata.ts | 19 + apps/api/src/app/models/ERC721Token.ts | 23 + apps/api/src/app/models/ERC721Transfer.ts | 20 + apps/api/src/app/models/Event.ts | 16 + apps/api/src/app/models/Identity.ts | 16 + apps/api/src/app/models/Invoice.ts | 24 + apps/api/src/app/models/Job.ts | 18 + apps/api/src/app/models/Notification.ts | 18 + apps/api/src/app/models/Participant.ts | 21 + apps/api/src/app/models/Payment.ts | 15 + apps/api/src/app/models/Pool.ts | 52 + apps/api/src/app/models/QRCodeEntry.ts | 19 + apps/api/src/app/models/Quest.ts | 13 + apps/api/src/app/models/QuestCustom.ts | 18 + apps/api/src/app/models/QuestCustomEntry.ts | 19 + apps/api/src/app/models/QuestDaily.ts | 17 + apps/api/src/app/models/QuestDailyEntry.ts | 22 + apps/api/src/app/models/QuestGitcoin.ts | 18 + apps/api/src/app/models/QuestGitcoinEntry.ts | 21 + apps/api/src/app/models/QuestInvite.ts | 20 + apps/api/src/app/models/QuestInviteEntry.ts | 20 + apps/api/src/app/models/QuestSocial.ts | 21 + apps/api/src/app/models/QuestSocialEntry.ts | 34 + apps/api/src/app/models/QuestWeb3.ts | 22 + apps/api/src/app/models/QuestWeb3Entry.ts | 22 + apps/api/src/app/models/QuestWebhook.ts | 18 + apps/api/src/app/models/QuestWebhookEntry.ts | 19 + apps/api/src/app/models/Reward.ts | 22 + apps/api/src/app/models/RewardCoin.ts | 19 + apps/api/src/app/models/RewardCoinPayment.ts | 17 + apps/api/src/app/models/RewardCoupon.ts | 18 + .../api/src/app/models/RewardCouponPayment.ts | 16 + apps/api/src/app/models/RewardCustom.ts | 19 + .../api/src/app/models/RewardCustomPayment.ts | 15 + apps/api/src/app/models/RewardDiscordRole.ts | 18 + .../app/models/RewardDiscordRolePayment.ts | 16 + apps/api/src/app/models/RewardGalachain.ts | 26 + .../src/app/models/RewardGalachainPayment.ts | 16 + apps/api/src/app/models/RewardNFT.ts | 25 + apps/api/src/app/models/RewardNFTPayment.ts | 15 + apps/api/src/app/models/Transaction.ts | 30 + apps/api/src/app/models/TwitterFollower.ts | 17 + apps/api/src/app/models/TwitterLike.ts | 15 + apps/api/src/app/models/TwitterPost.ts | 25 + apps/api/src/app/models/TwitterQuery.ts | 33 + apps/api/src/app/models/TwitterRepost.ts | 15 + apps/api/src/app/models/TwitterUser.ts | 24 + apps/api/src/app/models/Wallet.ts | 22 + apps/api/src/app/models/Webhook.ts | 17 + apps/api/src/app/models/WebhookRequest.ts | 19 + apps/api/src/app/models/Widget.ts | 23 + apps/api/src/app/models/index.ts | 61 + apps/api/src/app/proxies/AccountProxy.ts | 113 + apps/api/src/app/proxies/ClientProxy.ts | 87 + apps/api/src/app/proxies/DiscordDataProxy.ts | 156 + apps/api/src/app/proxies/TwitterDataProxy.ts | 442 + apps/api/src/app/proxies/YoutubeDataProxy.ts | 53 + apps/api/src/app/services/AnalyticsService.ts | 552 ++ apps/api/src/app/services/BalancerService.ts | 241 + apps/api/src/app/services/BrandService.ts | 10 + apps/api/src/app/services/CanvasService.ts | 79 + apps/api/src/app/services/ClaimService.ts | 50 + apps/api/src/app/services/ContractService.ts | 52 + apps/api/src/app/services/DiscordService.ts | 57 + apps/api/src/app/services/ERC1155Service.ts | 351 + apps/api/src/app/services/ERC20Service.ts | 339 + apps/api/src/app/services/ERC721Service.ts | 289 + apps/api/src/app/services/GalachainService.ts | 205 + apps/api/src/app/services/GitcoinService.ts | 32 + apps/api/src/app/services/IPFSService.ts | 44 + apps/api/src/app/services/IdentityService.ts | 33 + apps/api/src/app/services/ImageService.ts | 29 + apps/api/src/app/services/InvoiceService.ts | 141 + apps/api/src/app/services/LiquidityService.ts | 27 + apps/api/src/app/services/LockService.ts | 50 + apps/api/src/app/services/MailService.ts | 33 + .../src/app/services/NotificationService.ts | 142 + .../src/app/services/ParticipantService.ts | 60 + apps/api/src/app/services/PaymentService.ts | 112 + .../src/app/services/PointBalanceService.ts | 29 + apps/api/src/app/services/PoolService.ts | 406 + .../src/app/services/QuestCustomService.ts | 126 + .../api/src/app/services/QuestDailyService.ts | 206 + .../src/app/services/QuestDiscordService.ts | 193 + .../src/app/services/QuestGitcoinService.ts | 75 + .../src/app/services/QuestInviteService.ts | 44 + apps/api/src/app/services/QuestService.ts | 260 + .../src/app/services/QuestSocialService.ts | 100 + apps/api/src/app/services/QuestWeb3Service.ts | 115 + .../src/app/services/QuestWebhookService.ts | 114 + apps/api/src/app/services/ReCaptchaService.ts | 39 + .../api/src/app/services/RewardCoinService.ts | 140 + .../src/app/services/RewardCouponService.ts | 75 + .../src/app/services/RewardCustomService.ts | 68 + .../app/services/RewardDiscordRoleService.ts | 95 + .../app/services/RewardGalachainService.ts | 156 + apps/api/src/app/services/RewardNFTService.ts | 180 + apps/api/src/app/services/RewardService.ts | 292 + apps/api/src/app/services/SafeService.ts | 249 + apps/api/src/app/services/THXService.ts | 37 + .../src/app/services/TransactionService.ts | 365 + .../src/app/services/TwitterCacheService.ts | 264 + .../src/app/services/TwitterQueryService.ts | 131 + .../api/src/app/services/VoteEscrowService.ts | 200 + apps/api/src/app/services/WalletService.ts | 66 + apps/api/src/app/services/WebhookService.ts | 94 + apps/api/src/app/services/index.ts | 49 + .../app/services/interfaces/IGalaService.ts | 26 + .../app/services/interfaces/IQuestService.ts | 38 + .../app/services/interfaces/IRewardService.ts | 32 + apps/api/src/app/services/maps/quests.ts | 114 + .../app/types/augment-express-request.d.ts | 11 + apps/api/src/app/types/cors.d.ts | 1 + apps/api/src/app/types/jsonwebtoken.d.ts | 1 + apps/api/src/app/types/migrate-mongo.d.ts | 110 + apps/api/src/app/types/morgan-json.d.ts | 1 + apps/api/src/app/types/morgan.d.ts | 1 + apps/api/src/app/types/swagger-autogen.d.ts | 1 + .../api/src/app/types/swagger-ui-express.d.ts | 1 + apps/api/src/app/util/agenda.ts | 64 + apps/api/src/app/util/alchemy.ts | 48 + apps/api/src/app/util/auth.ts | 52 + apps/api/src/app/util/code.ts | 12 + apps/api/src/app/util/condition.ts | 0 apps/api/src/app/util/database.ts | 59 + apps/api/src/app/util/date.ts | 18 + apps/api/src/app/util/dictionaries.ts | 52 + apps/api/src/app/util/discord.ts | 33 + apps/api/src/app/util/errors.ts | 186 + apps/api/src/app/util/events.ts | 74 + apps/api/src/app/util/galachain.ts | 48 + apps/api/src/app/util/healthcheck.ts | 34 + apps/api/src/app/util/helpers.ts | 23 + apps/api/src/app/util/index.ts | 1 + apps/api/src/app/util/ip.ts | 7 + apps/api/src/app/util/jest/config.ts | 91 + apps/api/src/app/util/jest/constants.ts | 89 + apps/api/src/app/util/jest/erc1155.ts | 52 + apps/api/src/app/util/jest/erc721.ts | 55 + apps/api/src/app/util/jest/images.ts | 6 + apps/api/src/app/util/jest/jwt.ts | 77 + apps/api/src/app/util/jest/mock.ts | 78 + apps/api/src/app/util/jest/network.ts | 50 + apps/api/src/app/util/jest/test.jpg | Bin 0 -> 57854 bytes apps/api/src/app/util/jwt.ts | 6 + apps/api/src/app/util/logger.ts | 14 + apps/api/src/app/util/multer.ts | 3 + apps/api/src/app/util/network.ts | 101 + apps/api/src/app/util/newrelic.ts | 12 + apps/api/src/app/util/pagination.ts | 45 + apps/api/src/app/util/path.ts | 4 + apps/api/src/app/util/polling.ts | 14 + apps/api/src/app/util/random.ts | 3 + apps/api/src/app/util/ratelimiter.ts | 6 + apps/api/src/app/util/s3.ts | 26 + apps/api/src/app/util/scopes.ts | 63 + apps/api/src/app/util/signingsecret.ts | 11 + apps/api/src/app/util/token.ts | 6 + apps/api/src/app/util/twitter.ts | 8 + apps/api/src/app/util/url.ts | 2 + apps/api/src/app/util/uuid.ts | 23 + apps/api/src/app/util/validation.ts | 65 + apps/api/src/app/util/zip.ts | 9 + apps/api/src/assets/.gitkeep | 0 apps/api/src/assets/bg.png | Bin 0 -> 117866 bytes apps/api/src/assets/fa-solid-900.ttf | Bin 0 -> 383828 bytes apps/api/src/assets/logo.png | Bin 0 -> 16509 bytes apps/api/src/assets/qr-logo.jpg | Bin 0 -> 57854 bytes .../src/assets/views/email/base-template.ejs | 407 + apps/api/src/discord.ts | 21 + apps/api/src/environments/environment.prod.ts | 3 + apps/api/src/environments/environment.ts | 3 + apps/api/src/main.ts | 71 + apps/api/tsconfig.app.json | 18 + apps/api/tsconfig.json | 16 + apps/api/tsconfig.spec.json | 9 + apps/api/webpack.config.js | 13 + apps/app/project.json | 4 +- .../components/button/BaseButtonApprove.vue | 2 +- .../button/BaseButtonLiquidityCreate.vue | 2 +- .../button/BaseButtonLiquidityStake.vue | 2 +- .../card/BaseCardMembershipOnboarding.vue | 2 +- .../components/card/BaseCardQuestGitcoin.vue | 4 +- .../src/components/card/BaseCardQuestWeb3.vue | 2 +- .../dropdown/BaseDropdownMetricReward.vue | 2 +- .../dropdown/BaseDropdownMetricRewards.vue | 2 +- .../dropdown/BaseDropdownMetricTVL.vue | 2 +- .../formgroup/BaseFormGroupLockAmount.vue | 2 +- .../components/modal/BaseModalClaimTokens.vue | 2 +- .../modal/BaseModalCreateLiquidity.vue | 2 +- .../src/components/modal/BaseModalDeposit.vue | 2 +- .../modal/BaseModalIncreaseAmount.vue | 2 +- .../modal/BaseModalMembershipCreate.vue | 2 +- .../src/components/modal/BaseModalStake.vue | 2 +- .../components/modal/BaseModalTokenSelect.vue | 2 +- .../modal/BaseModalWalletConnect.vue | 2 +- .../src/components/tabs/BaseTabDeposit.vue | 2 +- .../src/components/tabs/BaseTabLiquidity.vue | 2 +- .../src/components/tabs/BaseTabWithdraw.vue | 2 +- apps/app/src/main.ts | 4 +- apps/app/src/stores/Account.ts | 4 +- apps/app/src/stores/Auth.ts | 2 +- apps/app/src/stores/Liquidity.ts | 2 +- apps/app/src/stores/QRCode.ts | 2 +- apps/app/src/stores/Quest.ts | 4 +- apps/app/src/stores/Reward.ts | 2 +- apps/app/src/stores/VE.ts | 2 +- apps/app/src/stores/Wallet.ts | 4 +- apps/app/src/utils/chains.ts | 2 +- apps/app/src/views/Campaign.vue | 2 +- apps/app/tsconfig.json | 3 +- libs/common/project.json | 4 +- libs/common/src/index.ts | 3 +- libs/common/src/lib/chains.ts | 61 + libs/common/src/lib/constants.ts | 250 + libs/common/src/lib/enums/AccessTokenKind.ts | 72 + libs/common/src/lib/enums/AccountPlanType.ts | 4 + libs/common/src/lib/enums/AccountVariant.ts | 18 + libs/common/src/lib/enums/ChainId.ts | 9 + libs/common/src/lib/enums/Collaborator.ts | 5 + .../src/lib/enums/DailyRewardClaimState.ts | 4 + libs/common/src/lib/enums/ERC1155.ts | 7 + libs/common/src/lib/enums/ERC20Type.ts | 5 + libs/common/src/lib/enums/ERC721Variant.ts | 14 + libs/common/src/lib/enums/Event.ts | 9 + libs/common/src/lib/enums/GateVariant.ts | 6 + libs/common/src/lib/enums/GrantVariant.ts | 4 + libs/common/src/lib/enums/Job.ts | 19 + libs/common/src/lib/enums/NFTVariant.ts | 4 + libs/common/src/lib/enums/PlatformVariant.ts | 7 + .../src/lib/enums/QuestSocialRequirement.ts | 13 + .../src/lib/enums/RewardConditionPlatform.ts | 10 + libs/common/src/lib/enums/RewardVariant.ts | 20 + libs/common/src/lib/enums/Signup.ts | 16 + libs/common/src/lib/enums/TransactionState.ts | 7 + libs/common/src/lib/enums/TransactionType.ts | 4 + libs/common/src/lib/enums/Wallet.ts | 4 + libs/common/src/lib/enums/Webhook.ts | 16 + libs/common/src/lib/enums/index.ts | 22 + libs/common/src/lib/index.ts | 8 + libs/common/src/lib/mail.ts | 29 + libs/common/src/lib/maps/index.ts | 2 + libs/common/src/lib/maps/oauth.ts | 39 + libs/common/src/lib/maps/quest.ts | 15 + libs/common/src/lib/migrate-mongo.ts | 54 + libs/common/src/lib/scss/_alert.scss | 9 + libs/common/src/lib/scss/_buttons.scss | 112 + libs/common/src/lib/scss/_card.scss | 27 + libs/common/src/lib/scss/_contact.scss | 27 + libs/common/src/lib/scss/_custom-forms.scss | 57 + libs/common/src/lib/scss/_custom-switch.scss | 83 + libs/common/src/lib/scss/_dark-mode.scss | 253 + libs/common/src/lib/scss/_dropdown.scss | 24 + libs/common/src/lib/scss/_faq.scss | 88 + libs/common/src/lib/scss/_forms.scss | 13 + libs/common/src/lib/scss/_glossary.scss | 100 + libs/common/src/lib/scss/_hooper.scss | 119 + libs/common/src/lib/scss/_identicon.scss | 15 + libs/common/src/lib/scss/_jumbotron.scss | 76 + libs/common/src/lib/scss/_modals.scss | 24 + libs/common/src/lib/scss/_navbar.scss | 284 + libs/common/src/lib/scss/_number.scss | 11 + libs/common/src/lib/scss/_popover.scss | 6 + libs/common/src/lib/scss/_root.scss | 246 + libs/common/src/lib/scss/_sidebar.scss | 127 + libs/common/src/lib/scss/_tab.scss | 12 + libs/common/src/lib/scss/_team.scss | 0 .../src/lib/scss/_token-distribution.scss | 25 + libs/common/src/lib/scss/_utilities.scss | 39 + libs/common/src/lib/scss/_variables.scss | 1125 +++ libs/common/src/lib/twitter.ts | 124 + libs/common/src/lib/types/Account.d.ts | 25 + libs/common/src/lib/types/Brand.d.ts | 7 + libs/common/src/lib/types/Claim.d.ts | 11 + libs/common/src/lib/types/Client.d.ts | 17 + libs/common/src/lib/types/Collaborator.d.ts | 9 + libs/common/src/lib/types/DiscordBot.d.ts | 81 + .../src/lib/types/DiscordRoleReward.d.ts | 8 + libs/common/src/lib/types/ERC1155.d.ts | 52 + libs/common/src/lib/types/ERC1155Perk.d.ts | 6 + .../src/lib/types/ERC1155PerkPayment.d.ts | 6 + libs/common/src/lib/types/ERC20.d.ts | 35 + libs/common/src/lib/types/ERC721.d.ts | 56 + libs/common/src/lib/types/Event.d.ts | 8 + libs/common/src/lib/types/Galachain.d.ts | 13 + libs/common/src/lib/types/Identity.d.ts | 8 + libs/common/src/lib/types/Interaction.d.ts | 22 + libs/common/src/lib/types/Invoice.d.ts | 13 + libs/common/src/lib/types/Job.d.ts | 1 + libs/common/src/lib/types/Notification.d.ts | 8 + libs/common/src/lib/types/Pagination.d.ts | 12 + libs/common/src/lib/types/Participant.d.ts | 12 + libs/common/src/lib/types/Payment.d.ts | 20 + libs/common/src/lib/types/Pool.d.ts | 78 + libs/common/src/lib/types/Quest.d.ts | 35 + libs/common/src/lib/types/QuestCustom.d.ts | 5 + .../src/lib/types/QuestCustomEntry.d.ts | 10 + libs/common/src/lib/types/QuestDaily.d.ts | 7 + .../common/src/lib/types/QuestDailyEntry.d.ts | 14 + libs/common/src/lib/types/QuestGitcoin.d.ts | 19 + libs/common/src/lib/types/QuestInvite.d.ts | 7 + .../src/lib/types/QuestInviteEntry.d.ts | 10 + libs/common/src/lib/types/QuestSocial.d.ts | 9 + .../src/lib/types/QuestSocialEntry.d.ts | 17 + libs/common/src/lib/types/QuestWebhook.d.ts | 5 + .../src/lib/types/QuestWebhookEntry.d.ts | 9 + libs/common/src/lib/types/Reward.d.ts | 45 + libs/common/src/lib/types/RewardCoin.d.ts | 4 + .../src/lib/types/RewardCoinPayment.d.ts | 3 + libs/common/src/lib/types/RewardCoupon.d.ts | 19 + libs/common/src/lib/types/RewardCustom.d.ts | 8 + .../common/src/lib/types/RewardGalachain.d.ts | 12 + .../src/lib/types/RewardGalachainPayment.d.ts | 3 + libs/common/src/lib/types/RewardNFT.d.ts | 13 + .../src/lib/types/RewardNFTPayment.d.ts | 3 + libs/common/src/lib/types/Token.d.ts | 12 + libs/common/src/lib/types/Transaction.d.ts | 245 + libs/common/src/lib/types/Twitter.d.ts | 136 + libs/common/src/lib/types/Wallet.d.ts | 13 + libs/common/src/lib/types/Web3Quest.d.ts | 22 + libs/common/src/lib/types/Webhook.d.ts | 22 + libs/common/src/lib/types/Widget.d.ts | 14 + libs/common/tsconfig.json | 2 + libs/sdk/.eslintrc.json | 25 + libs/sdk/README.md | 250 + libs/sdk/package.json | 11 + libs/sdk/project.json | 22 + libs/sdk/src/index.ts | 3 + libs/sdk/src/lib/clients/API.ts | 22 + libs/sdk/src/lib/clients/Browser.ts | 44 + libs/sdk/src/lib/clients/Widget.ts | 27 + libs/sdk/src/lib/clients/index.ts | 5 + libs/sdk/src/lib/managers/AccountManager.ts | 29 + libs/sdk/src/lib/managers/BaseManager.ts | 9 + libs/sdk/src/lib/managers/CampaignManager.ts | 28 + .../sdk/src/lib/managers/CouponCodeManager.ts | 15 + libs/sdk/src/lib/managers/ERC1155Manager.ts | 31 + libs/sdk/src/lib/managers/ERC20Manager.ts | 28 + libs/sdk/src/lib/managers/ERC721Manager.ts | 31 + libs/sdk/src/lib/managers/EventManager.ts | 28 + libs/sdk/src/lib/managers/IdentityManager.ts | 18 + libs/sdk/src/lib/managers/OIDCManager.ts | 123 + .../src/lib/managers/PointBalanceManager.ts | 14 + libs/sdk/src/lib/managers/PoolManager.ts | 34 + libs/sdk/src/lib/managers/QRCodeManager.ts | 20 + libs/sdk/src/lib/managers/QuestManager.ts | 68 + libs/sdk/src/lib/managers/RequestManager.ts | 104 + libs/sdk/src/lib/managers/RewardManager.ts | 73 + libs/sdk/src/lib/types/enums/ChainId.ts | 10 + libs/sdk/src/lib/types/enums/Quests.ts | 24 + libs/sdk/src/lib/types/enums/index.ts | 2 + libs/sdk/src/lib/types/index.ts | 93 + libs/sdk/tsconfig.json | 19 + libs/sdk/tsconfig.lib.json | 10 + migrations.json | 334 - newrelic.js | 36 + nx.json | 16 +- package.json | 91 +- tsconfig.base.json | 6 +- yarn.lock | 7251 +++++++++++++++-- 717 files changed, 44868 insertions(+), 969 deletions(-) create mode 100644 .env.example create mode 100644 apps/api/.env.example create mode 100644 apps/api/.eslintrc.json create mode 100644 apps/api/Dockerfile create mode 100644 apps/api/jest.config.ts create mode 100644 apps/api/package.json create mode 100644 apps/api/project.json create mode 100644 apps/api/scripts/migrate-mongo.ts create mode 100644 apps/api/scripts/script.ts create mode 100644 apps/api/scripts/src/demo.ts create mode 100644 apps/api/scripts/src/galachain.ts create mode 100644 apps/api/scripts/src/invoices.ts create mode 100644 apps/api/scripts/src/ipfs.ts create mode 100644 apps/api/scripts/src/metamask.ts create mode 100644 apps/api/scripts/src/preview.ts create mode 100644 apps/api/scripts/src/safe.ts create mode 100644 apps/api/scripts/src/sdk.ts create mode 100644 apps/api/scripts/src/time.ts create mode 100644 apps/api/scripts/src/utils/index.ts create mode 100644 apps/api/scripts/src/veLiquidity.ts create mode 100644 apps/api/scripts/src/veRewards.ts create mode 100644 apps/api/scripts/src/veTransfer.ts create mode 100644 apps/api/scripts/upgradeContractsToLatest.ts create mode 100644 apps/api/sonar-project.properties create mode 100644 apps/api/src/app/config/migrate-mongo-create-only.json create mode 100644 apps/api/src/app/config/migrate-mongo.ts create mode 100644 apps/api/src/app/config/secrets.ts create mode 100644 apps/api/src/app/connection-profiles/cpp-curator.json create mode 100644 apps/api/src/app/connection-profiles/cpp-partner.json create mode 100644 apps/api/src/app/connection-profiles/cpp-users.json create mode 100644 apps/api/src/app/contracts/abis/BAL.json create mode 100644 apps/api/src/app/contracts/abis/BPT.json create mode 100644 apps/api/src/app/contracts/abis/BPTGauge.json create mode 100644 apps/api/src/app/contracts/abis/BalancerGaugeController.json create mode 100644 apps/api/src/app/contracts/abis/BalancerMinter.json create mode 100644 apps/api/src/app/contracts/abis/BalancerVault.json create mode 100644 apps/api/src/app/contracts/abis/Launchpad.json create mode 100644 apps/api/src/app/contracts/abis/LensReward.json create mode 100644 apps/api/src/app/contracts/abis/LimitedSupplyToken.json create mode 100644 apps/api/src/app/contracts/abis/NonFungibleToken.json create mode 100644 apps/api/src/app/contracts/abis/RewardDistributor.json create mode 100644 apps/api/src/app/contracts/abis/RewardFaucet.json create mode 100644 apps/api/src/app/contracts/abis/SmartWalletWhitelist.json create mode 100644 apps/api/src/app/contracts/abis/THX.json create mode 100644 apps/api/src/app/contracts/abis/THXPaymentSplitter.json create mode 100644 apps/api/src/app/contracts/abis/THXRegistry.json create mode 100644 apps/api/src/app/contracts/abis/THX_ERC1155.json create mode 100644 apps/api/src/app/contracts/abis/TestToken.json create mode 100644 apps/api/src/app/contracts/abis/USDC.json create mode 100644 apps/api/src/app/contracts/abis/UnlimitedSupplyToken.json create mode 100644 apps/api/src/app/contracts/abis/VotingEscrow.json create mode 100644 apps/api/src/app/contracts/bytecodes/BPT.json create mode 100644 apps/api/src/app/contracts/bytecodes/LimitedSupplyToken.json create mode 100644 apps/api/src/app/contracts/bytecodes/NonFungibleToken.json create mode 100644 apps/api/src/app/contracts/bytecodes/THX_ERC1155.json create mode 100644 apps/api/src/app/contracts/bytecodes/UnlimitedSupplyToken.json create mode 100644 apps/api/src/app/contracts/index.ts create mode 100644 apps/api/src/app/controllers/account/account.router.ts create mode 100644 apps/api/src/app/controllers/account/delete.controller.ts create mode 100644 apps/api/src/app/controllers/account/disconnect/post.controller.ts create mode 100644 apps/api/src/app/controllers/account/discord/get.by-discord-id.controller.ts create mode 100644 apps/api/src/app/controllers/account/discord/get.controller.ts create mode 100644 apps/api/src/app/controllers/account/get.controller.ts create mode 100644 apps/api/src/app/controllers/account/patch.controller.ts create mode 100644 apps/api/src/app/controllers/account/twitter/search/post.controller.ts create mode 100644 apps/api/src/app/controllers/account/twitter/tweet/post.controller.ts create mode 100644 apps/api/src/app/controllers/account/twitter/user/by/username/post.controller.ts create mode 100644 apps/api/src/app/controllers/account/twitter/user/post.controller.ts create mode 100644 apps/api/src/app/controllers/account/wallet/confirm/post.controller.ts create mode 100644 apps/api/src/app/controllers/account/wallet/list.controller.ts create mode 100644 apps/api/src/app/controllers/account/wallet/post.controller.ts create mode 100644 apps/api/src/app/controllers/account/wallet/wallets.router.ts create mode 100644 apps/api/src/app/controllers/account/wallet/wallets.test.ts create mode 100644 apps/api/src/app/controllers/brands/brands.router.ts create mode 100644 apps/api/src/app/controllers/brands/get.controller.ts create mode 100644 apps/api/src/app/controllers/brands/put.controller.ts create mode 100644 apps/api/src/app/controllers/client/client.router.ts create mode 100644 apps/api/src/app/controllers/client/list.controller.ts create mode 100644 apps/api/src/app/controllers/client/patch.controller.ts create mode 100644 apps/api/src/app/controllers/client/post.controller.ts create mode 100644 apps/api/src/app/controllers/coupons/coupons.router.ts create mode 100644 apps/api/src/app/controllers/coupons/delete.controller.ts create mode 100644 apps/api/src/app/controllers/coupons/list.controller.ts create mode 100644 apps/api/src/app/controllers/data/data.router.ts create mode 100644 apps/api/src/app/controllers/earn/earn.router.ts create mode 100644 apps/api/src/app/controllers/earn/metrics/list.controller.ts create mode 100644 apps/api/src/app/controllers/earn/prices/list.controller.ts create mode 100644 apps/api/src/app/controllers/erc1155/delete.controller.ts create mode 100644 apps/api/src/app/controllers/erc1155/erc1155.router.ts create mode 100644 apps/api/src/app/controllers/erc1155/erc1155.test.ts create mode 100644 apps/api/src/app/controllers/erc1155/get.controller.ts create mode 100644 apps/api/src/app/controllers/erc1155/import/erc1155-import.test.ts create mode 100644 apps/api/src/app/controllers/erc1155/import/post.controller.ts create mode 100644 apps/api/src/app/controllers/erc1155/import/preview/post.controller.ts create mode 100644 apps/api/src/app/controllers/erc1155/list.controller.ts create mode 100644 apps/api/src/app/controllers/erc1155/metadata/delete.controller.ts create mode 100644 apps/api/src/app/controllers/erc1155/metadata/get.controller.ts create mode 100644 apps/api/src/app/controllers/erc1155/metadata/list.controller.ts create mode 100644 apps/api/src/app/controllers/erc1155/metadata/patch.controller.ts create mode 100644 apps/api/src/app/controllers/erc1155/metadata/post.controller.ts create mode 100644 apps/api/src/app/controllers/erc1155/patch.controller.ts create mode 100644 apps/api/src/app/controllers/erc1155/post.controller.ts create mode 100644 apps/api/src/app/controllers/erc1155/token/get.controller.ts create mode 100644 apps/api/src/app/controllers/erc1155/token/list.controller.ts create mode 100644 apps/api/src/app/controllers/erc1155/transfer/post.controller.ts create mode 100644 apps/api/src/app/controllers/erc20/allowance/allowance.router.ts create mode 100644 apps/api/src/app/controllers/erc20/allowance/get.controller.ts create mode 100644 apps/api/src/app/controllers/erc20/allowance/post.controller.ts create mode 100644 apps/api/src/app/controllers/erc20/balance/balance.router.ts create mode 100644 apps/api/src/app/controllers/erc20/balance/get.controller.ts create mode 100644 apps/api/src/app/controllers/erc20/delete.controller.ts create mode 100644 apps/api/src/app/controllers/erc20/erc20.router.ts create mode 100644 apps/api/src/app/controllers/erc20/erc20.test.ts create mode 100644 apps/api/src/app/controllers/erc20/get.controller.ts create mode 100644 apps/api/src/app/controllers/erc20/list.controller.ts create mode 100644 apps/api/src/app/controllers/erc20/patch.controller.ts create mode 100644 apps/api/src/app/controllers/erc20/post.controller.ts create mode 100644 apps/api/src/app/controllers/erc20/preview/get.controller.ts create mode 100644 apps/api/src/app/controllers/erc20/preview/preview.router.ts create mode 100644 apps/api/src/app/controllers/erc20/token/get.controller.ts create mode 100644 apps/api/src/app/controllers/erc20/token/list.controller.ts create mode 100644 apps/api/src/app/controllers/erc20/token/post.controller.ts create mode 100644 apps/api/src/app/controllers/erc20/token/token.router.ts create mode 100644 apps/api/src/app/controllers/erc20/transfer/erc20-transfer.test.ts create mode 100644 apps/api/src/app/controllers/erc20/transfer/post.controller.ts create mode 100644 apps/api/src/app/controllers/erc20/transfer/transfer.router.ts create mode 100644 apps/api/src/app/controllers/erc721/delete.controller.ts create mode 100644 apps/api/src/app/controllers/erc721/erc721.router.ts create mode 100644 apps/api/src/app/controllers/erc721/erc721.test.ts create mode 100644 apps/api/src/app/controllers/erc721/get.controller.ts create mode 100644 apps/api/src/app/controllers/erc721/import/erc721-import.test.ts create mode 100644 apps/api/src/app/controllers/erc721/import/post.controller.ts create mode 100644 apps/api/src/app/controllers/erc721/import/preview/post.controller.ts create mode 100644 apps/api/src/app/controllers/erc721/list.controller.ts create mode 100644 apps/api/src/app/controllers/erc721/metadata/delete.controller.ts create mode 100644 apps/api/src/app/controllers/erc721/metadata/erc721-metadata.test.ts create mode 100644 apps/api/src/app/controllers/erc721/metadata/get.controller.ts create mode 100644 apps/api/src/app/controllers/erc721/metadata/images/post.controller.ts create mode 100644 apps/api/src/app/controllers/erc721/metadata/list.controller.ts create mode 100644 apps/api/src/app/controllers/erc721/metadata/patch.controller.ts create mode 100644 apps/api/src/app/controllers/erc721/metadata/post.controller.ts create mode 100644 apps/api/src/app/controllers/erc721/patch.controller.ts create mode 100644 apps/api/src/app/controllers/erc721/post.controller.ts create mode 100644 apps/api/src/app/controllers/erc721/token/get.controller.ts create mode 100644 apps/api/src/app/controllers/erc721/token/list.controller.ts create mode 100644 apps/api/src/app/controllers/erc721/transfer/erc721-transfer.test.ts create mode 100644 apps/api/src/app/controllers/erc721/transfer/post.controller.ts create mode 100644 apps/api/src/app/controllers/events/events.router.ts create mode 100644 apps/api/src/app/controllers/events/post.controller.ts create mode 100644 apps/api/src/app/controllers/health/health.router.ts create mode 100644 apps/api/src/app/controllers/health/list.controller.ts create mode 100644 apps/api/src/app/controllers/identity/get.controller.ts create mode 100644 apps/api/src/app/controllers/identity/identity.router.ts create mode 100644 apps/api/src/app/controllers/identity/patch.controller.ts create mode 100644 apps/api/src/app/controllers/identity/post.controller.ts create mode 100644 apps/api/src/app/controllers/index.ts create mode 100644 apps/api/src/app/controllers/jobs/get.controller.ts create mode 100644 apps/api/src/app/controllers/jobs/jobs.router.ts create mode 100644 apps/api/src/app/controllers/leaderboards/get.controller.ts create mode 100644 apps/api/src/app/controllers/leaderboards/leaderboards.router.ts create mode 100644 apps/api/src/app/controllers/leaderboards/list.controller.ts create mode 100644 apps/api/src/app/controllers/liquidity/liquidity.router.ts create mode 100644 apps/api/src/app/controllers/liquidity/post.controller.ts create mode 100644 apps/api/src/app/controllers/liquidity/stake/post.controller.ts create mode 100644 apps/api/src/app/controllers/metadata/erc1155/get.controller.ts create mode 100644 apps/api/src/app/controllers/metadata/get.controller.ts create mode 100644 apps/api/src/app/controllers/metadata/metadata.router.ts create mode 100644 apps/api/src/app/controllers/participants/get.controller.ts create mode 100644 apps/api/src/app/controllers/participants/participants.router.ts create mode 100644 apps/api/src/app/controllers/participants/patch.controller.ts create mode 100644 apps/api/src/app/controllers/pools/analytics/analytics.router.ts create mode 100644 apps/api/src/app/controllers/pools/analytics/list.controller.ts create mode 100644 apps/api/src/app/controllers/pools/analytics/metrics/list.controller.ts create mode 100644 apps/api/src/app/controllers/pools/analytics/metrics/metrics.router.ts create mode 100644 apps/api/src/app/controllers/pools/collaborators/collaborators.router.ts create mode 100644 apps/api/src/app/controllers/pools/collaborators/delete.controller.ts create mode 100644 apps/api/src/app/controllers/pools/collaborators/patch.controller.ts create mode 100644 apps/api/src/app/controllers/pools/collaborators/post.controller.ts create mode 100644 apps/api/src/app/controllers/pools/delete.controller.ts create mode 100644 apps/api/src/app/controllers/pools/erc1155/balance/balance.router.ts create mode 100644 apps/api/src/app/controllers/pools/erc1155/balance/get.controller.ts create mode 100644 apps/api/src/app/controllers/pools/erc1155/erc1155.router.ts create mode 100644 apps/api/src/app/controllers/pools/erc20/allowance/allowance.router.ts create mode 100644 apps/api/src/app/controllers/pools/erc20/allowance/get.controller.ts create mode 100644 apps/api/src/app/controllers/pools/erc20/allowance/post.controller.ts create mode 100644 apps/api/src/app/controllers/pools/erc20/balance/balance.router.ts create mode 100644 apps/api/src/app/controllers/pools/erc20/balance/get.controller.ts create mode 100644 apps/api/src/app/controllers/pools/erc20/erc20.router.ts create mode 100644 apps/api/src/app/controllers/pools/events/events.router.ts create mode 100644 apps/api/src/app/controllers/pools/events/list.controller.ts create mode 100644 apps/api/src/app/controllers/pools/get.controller.ts create mode 100644 apps/api/src/app/controllers/pools/guilds/delete.controller.ts create mode 100644 apps/api/src/app/controllers/pools/guilds/guilds.router.ts create mode 100644 apps/api/src/app/controllers/pools/guilds/list.controller.ts create mode 100644 apps/api/src/app/controllers/pools/guilds/patch.controller.ts create mode 100644 apps/api/src/app/controllers/pools/guilds/post.controller.ts create mode 100644 apps/api/src/app/controllers/pools/identities/delete.controller.ts create mode 100644 apps/api/src/app/controllers/pools/identities/get.controller.ts create mode 100644 apps/api/src/app/controllers/pools/identities/identities.router.ts create mode 100644 apps/api/src/app/controllers/pools/identities/post.controller.ts create mode 100644 apps/api/src/app/controllers/pools/integrations/integrations.router.ts create mode 100644 apps/api/src/app/controllers/pools/integrations/twitter/queries/delete.controller.ts create mode 100644 apps/api/src/app/controllers/pools/integrations/twitter/queries/list.controller.ts create mode 100644 apps/api/src/app/controllers/pools/integrations/twitter/queries/patch.controller.ts create mode 100644 apps/api/src/app/controllers/pools/integrations/twitter/queries/post.controller.ts create mode 100644 apps/api/src/app/controllers/pools/integrations/twitter/twitter.router.ts create mode 100644 apps/api/src/app/controllers/pools/invoices/invoices.router.ts create mode 100644 apps/api/src/app/controllers/pools/invoices/list.controller.ts create mode 100644 apps/api/src/app/controllers/pools/list.controller.ts create mode 100644 apps/api/src/app/controllers/pools/participants/list.controller.ts create mode 100644 apps/api/src/app/controllers/pools/participants/participants.router.ts create mode 100644 apps/api/src/app/controllers/pools/participants/patch.controller.ts create mode 100644 apps/api/src/app/controllers/pools/patch.controller.ts create mode 100644 apps/api/src/app/controllers/pools/payments/payments.router.ts create mode 100644 apps/api/src/app/controllers/pools/payments/post.controller.ts create mode 100644 apps/api/src/app/controllers/pools/pools.router.ts create mode 100644 apps/api/src/app/controllers/pools/pools.test.ts create mode 100644 apps/api/src/app/controllers/pools/post.controller.ts create mode 100644 apps/api/src/app/controllers/pools/quests/delete.controller.ts create mode 100644 apps/api/src/app/controllers/pools/quests/entries/entries.router.ts create mode 100644 apps/api/src/app/controllers/pools/quests/entries/list.controller.ts create mode 100644 apps/api/src/app/controllers/pools/quests/list.controller.ts create mode 100644 apps/api/src/app/controllers/pools/quests/patch.controller.ts create mode 100644 apps/api/src/app/controllers/pools/quests/post.controller.ts create mode 100644 apps/api/src/app/controllers/pools/quests/quests.router.ts create mode 100644 apps/api/src/app/controllers/pools/rewards/coin-rewards.test.ts create mode 100644 apps/api/src/app/controllers/pools/rewards/delete.controller.ts create mode 100644 apps/api/src/app/controllers/pools/rewards/discord-role-rewards.router.ts create mode 100644 apps/api/src/app/controllers/pools/rewards/list.controller.ts create mode 100644 apps/api/src/app/controllers/pools/rewards/nft-rewards.test.ts create mode 100644 apps/api/src/app/controllers/pools/rewards/patch.controller.ts create mode 100644 apps/api/src/app/controllers/pools/rewards/payments/list.controller.ts create mode 100644 apps/api/src/app/controllers/pools/rewards/payments/payments.router.ts create mode 100644 apps/api/src/app/controllers/pools/rewards/post.controller.ts create mode 100644 apps/api/src/app/controllers/pools/rewards/rewards.router.ts create mode 100644 apps/api/src/app/controllers/pools/wallets/list.controller.ts create mode 100644 apps/api/src/app/controllers/pools/wallets/wallets.router.ts create mode 100644 apps/api/src/app/controllers/qr-codes/collect/post.controller.ts create mode 100644 apps/api/src/app/controllers/qr-codes/delete.controller.ts create mode 100644 apps/api/src/app/controllers/qr-codes/get.controller.ts create mode 100644 apps/api/src/app/controllers/qr-codes/list.controller.ts create mode 100644 apps/api/src/app/controllers/qr-codes/post.controller.ts create mode 100644 apps/api/src/app/controllers/qr-codes/qr-codes.router.ts create mode 100644 apps/api/src/app/controllers/qr-codes/qr-codes.test.ts create mode 100644 apps/api/src/app/controllers/qr-codes/redirect/get.controller.ts create mode 100644 apps/api/src/app/controllers/quests/custom/custom.router.ts create mode 100644 apps/api/src/app/controllers/quests/custom/custom.test.ts create mode 100644 apps/api/src/app/controllers/quests/custom/entries/post.controller.ts create mode 100644 apps/api/src/app/controllers/quests/daily/daily.router.ts create mode 100644 apps/api/src/app/controllers/quests/daily/entries/post.controller.ts create mode 100644 apps/api/src/app/controllers/quests/gitcoin/entries/post.controller.ts create mode 100644 apps/api/src/app/controllers/quests/gitcoin/gitcoin.router.ts create mode 100644 apps/api/src/app/controllers/quests/list.controller.ts create mode 100644 apps/api/src/app/controllers/quests/quests.router.ts create mode 100644 apps/api/src/app/controllers/quests/recent/list.controller.ts create mode 100644 apps/api/src/app/controllers/quests/social/entries/post.controller.ts create mode 100644 apps/api/src/app/controllers/quests/social/social.router.ts create mode 100644 apps/api/src/app/controllers/quests/web3/entries/post.controller.ts create mode 100644 apps/api/src/app/controllers/quests/web3/web3.router.ts create mode 100644 apps/api/src/app/controllers/quests/webhook/entries/post.controller.ts create mode 100644 apps/api/src/app/controllers/quests/webhook/webhook.router.ts create mode 100644 apps/api/src/app/controllers/rewards/list.controller.ts create mode 100644 apps/api/src/app/controllers/rewards/payments/list.controller.ts create mode 100644 apps/api/src/app/controllers/rewards/payments/post.controller.ts create mode 100644 apps/api/src/app/controllers/rewards/rewards.router.ts create mode 100644 apps/api/src/app/controllers/token/cs/get.controller.ts create mode 100644 apps/api/src/app/controllers/token/token.router.ts create mode 100644 apps/api/src/app/controllers/token/ts/get.controller.ts create mode 100644 apps/api/src/app/controllers/transactions/list.controller.ts create mode 100644 apps/api/src/app/controllers/upload/put.controller.ts create mode 100644 apps/api/src/app/controllers/upload/upload.router.ts create mode 100644 apps/api/src/app/controllers/ve/claim/post.controller.ts create mode 100644 apps/api/src/app/controllers/ve/deposit/post.controller.ts create mode 100644 apps/api/src/app/controllers/ve/increase/post.controller.ts create mode 100644 apps/api/src/app/controllers/ve/list.controller.ts create mode 100644 apps/api/src/app/controllers/ve/ve.router.ts create mode 100644 apps/api/src/app/controllers/ve/ve.test.ts create mode 100644 apps/api/src/app/controllers/ve/withdraw/post.controller.ts create mode 100644 apps/api/src/app/controllers/webhook/daily/daily-quest-webhook.test.ts create mode 100644 apps/api/src/app/controllers/webhook/daily/post.controller.ts create mode 100644 apps/api/src/app/controllers/webhook/gateway/post.controller.ts create mode 100644 apps/api/src/app/controllers/webhook/milestones/claim/post.controller.ts create mode 100644 apps/api/src/app/controllers/webhook/webhook.router.ts create mode 100644 apps/api/src/app/controllers/webhooks/delete.controller.ts create mode 100644 apps/api/src/app/controllers/webhooks/list.controller.ts create mode 100644 apps/api/src/app/controllers/webhooks/patch.controller.ts create mode 100644 apps/api/src/app/controllers/webhooks/post.controller.ts create mode 100644 apps/api/src/app/controllers/webhooks/webhooks.router.ts create mode 100644 apps/api/src/app/controllers/widget/get.controller.ts create mode 100644 apps/api/src/app/controllers/widget/js/get.controller.ts create mode 100644 apps/api/src/app/controllers/widget/widget.router.ts create mode 100644 apps/api/src/app/controllers/widgets/get.controller.ts create mode 100644 apps/api/src/app/controllers/widgets/list.controller.ts create mode 100644 apps/api/src/app/controllers/widgets/patch.controller.ts create mode 100644 apps/api/src/app/controllers/widgets/widgets.router.ts create mode 100644 apps/api/src/app/controllers/widgets/widgets.test.ts create mode 100644 apps/api/src/app/events/ClientReady.ts create mode 100644 apps/api/src/app/events/GuildCreate.ts create mode 100644 apps/api/src/app/events/GuildDelete.ts create mode 100644 apps/api/src/app/events/InteractionCreated.ts create mode 100644 apps/api/src/app/events/MessageCreate.ts create mode 100644 apps/api/src/app/events/MessageReactionAdd.ts create mode 100644 apps/api/src/app/events/commands/error.ts create mode 100644 apps/api/src/app/events/commands/index.ts create mode 100644 apps/api/src/app/events/commands/thx.ts create mode 100644 apps/api/src/app/events/commands/thx/buy.ts create mode 100644 apps/api/src/app/events/commands/thx/index.ts create mode 100644 apps/api/src/app/events/commands/thx/info.ts create mode 100644 apps/api/src/app/events/commands/thx/points.ts create mode 100644 apps/api/src/app/events/commands/thx/quest.ts create mode 100644 apps/api/src/app/events/components/index.ts create mode 100644 apps/api/src/app/events/components/selectMenuQuests.ts create mode 100644 apps/api/src/app/events/components/selectMenuRewards.ts create mode 100644 apps/api/src/app/events/handlers/button/quest.ts create mode 100644 apps/api/src/app/events/handlers/button/reward.ts create mode 100644 apps/api/src/app/events/handlers/index.ts create mode 100644 apps/api/src/app/events/handlers/select/quest.ts create mode 100644 apps/api/src/app/events/index.ts create mode 100644 apps/api/src/app/index.ts create mode 100644 apps/api/src/app/jobs/createTwitterQuests.ts create mode 100644 apps/api/src/app/jobs/sendPoolAnalyticsReport.ts create mode 100644 apps/api/src/app/jobs/updateCampaignRanks.ts create mode 100644 apps/api/src/app/jobs/updateParticipantRanks.ts create mode 100644 apps/api/src/app/jobs/updatePendingTransactions.ts create mode 100644 apps/api/src/app/middlewares/assertAccount.ts create mode 100644 apps/api/src/app/middlewares/assertPayment.ts create mode 100644 apps/api/src/app/middlewares/assertPoolAccess.ts create mode 100644 apps/api/src/app/middlewares/assertQuestAccess.ts create mode 100644 apps/api/src/app/middlewares/assertRequestInput.ts create mode 100644 apps/api/src/app/middlewares/assertUUID.ts create mode 100644 apps/api/src/app/middlewares/assertWallet.ts create mode 100644 apps/api/src/app/middlewares/checkJwt.ts create mode 100644 apps/api/src/app/middlewares/corsHandler.ts create mode 100644 apps/api/src/app/middlewares/errorLogger.ts create mode 100644 apps/api/src/app/middlewares/errorNormalizer.ts create mode 100644 apps/api/src/app/middlewares/errorOutput.ts create mode 100644 apps/api/src/app/middlewares/index.ts create mode 100644 apps/api/src/app/middlewares/morgan.ts create mode 100644 apps/api/src/app/middlewares/notFoundHandler.ts create mode 100644 apps/api/src/app/migrations/20240321144151-galachain-pkey.js create mode 100644 apps/api/src/app/migrations/20240329123915-quest-metadata.js create mode 100644 apps/api/src/app/migrations/20240430124026-message-query.js create mode 100644 apps/api/src/app/models/Brand.ts create mode 100644 apps/api/src/app/models/Client.ts create mode 100644 apps/api/src/app/models/Collaborator.ts create mode 100644 apps/api/src/app/models/CouponCode.ts create mode 100644 apps/api/src/app/models/DiscordGuild.ts create mode 100644 apps/api/src/app/models/DiscordMessage.ts create mode 100644 apps/api/src/app/models/DiscordReaction.ts create mode 100644 apps/api/src/app/models/DiscordUser.ts create mode 100644 apps/api/src/app/models/ERC1155.ts create mode 100644 apps/api/src/app/models/ERC1155Metadata.ts create mode 100644 apps/api/src/app/models/ERC1155Token.ts create mode 100644 apps/api/src/app/models/ERC20.ts create mode 100644 apps/api/src/app/models/ERC20Token.ts create mode 100644 apps/api/src/app/models/ERC20Transfer.ts create mode 100644 apps/api/src/app/models/ERC721.ts create mode 100644 apps/api/src/app/models/ERC721Metadata.ts create mode 100644 apps/api/src/app/models/ERC721Token.ts create mode 100644 apps/api/src/app/models/ERC721Transfer.ts create mode 100644 apps/api/src/app/models/Event.ts create mode 100644 apps/api/src/app/models/Identity.ts create mode 100644 apps/api/src/app/models/Invoice.ts create mode 100644 apps/api/src/app/models/Job.ts create mode 100644 apps/api/src/app/models/Notification.ts create mode 100644 apps/api/src/app/models/Participant.ts create mode 100644 apps/api/src/app/models/Payment.ts create mode 100644 apps/api/src/app/models/Pool.ts create mode 100644 apps/api/src/app/models/QRCodeEntry.ts create mode 100644 apps/api/src/app/models/Quest.ts create mode 100644 apps/api/src/app/models/QuestCustom.ts create mode 100644 apps/api/src/app/models/QuestCustomEntry.ts create mode 100644 apps/api/src/app/models/QuestDaily.ts create mode 100644 apps/api/src/app/models/QuestDailyEntry.ts create mode 100644 apps/api/src/app/models/QuestGitcoin.ts create mode 100644 apps/api/src/app/models/QuestGitcoinEntry.ts create mode 100644 apps/api/src/app/models/QuestInvite.ts create mode 100644 apps/api/src/app/models/QuestInviteEntry.ts create mode 100644 apps/api/src/app/models/QuestSocial.ts create mode 100644 apps/api/src/app/models/QuestSocialEntry.ts create mode 100644 apps/api/src/app/models/QuestWeb3.ts create mode 100644 apps/api/src/app/models/QuestWeb3Entry.ts create mode 100644 apps/api/src/app/models/QuestWebhook.ts create mode 100644 apps/api/src/app/models/QuestWebhookEntry.ts create mode 100644 apps/api/src/app/models/Reward.ts create mode 100644 apps/api/src/app/models/RewardCoin.ts create mode 100644 apps/api/src/app/models/RewardCoinPayment.ts create mode 100644 apps/api/src/app/models/RewardCoupon.ts create mode 100644 apps/api/src/app/models/RewardCouponPayment.ts create mode 100644 apps/api/src/app/models/RewardCustom.ts create mode 100644 apps/api/src/app/models/RewardCustomPayment.ts create mode 100644 apps/api/src/app/models/RewardDiscordRole.ts create mode 100644 apps/api/src/app/models/RewardDiscordRolePayment.ts create mode 100644 apps/api/src/app/models/RewardGalachain.ts create mode 100644 apps/api/src/app/models/RewardGalachainPayment.ts create mode 100644 apps/api/src/app/models/RewardNFT.ts create mode 100644 apps/api/src/app/models/RewardNFTPayment.ts create mode 100644 apps/api/src/app/models/Transaction.ts create mode 100644 apps/api/src/app/models/TwitterFollower.ts create mode 100644 apps/api/src/app/models/TwitterLike.ts create mode 100644 apps/api/src/app/models/TwitterPost.ts create mode 100644 apps/api/src/app/models/TwitterQuery.ts create mode 100644 apps/api/src/app/models/TwitterRepost.ts create mode 100644 apps/api/src/app/models/TwitterUser.ts create mode 100644 apps/api/src/app/models/Wallet.ts create mode 100644 apps/api/src/app/models/Webhook.ts create mode 100644 apps/api/src/app/models/WebhookRequest.ts create mode 100644 apps/api/src/app/models/Widget.ts create mode 100644 apps/api/src/app/models/index.ts create mode 100644 apps/api/src/app/proxies/AccountProxy.ts create mode 100644 apps/api/src/app/proxies/ClientProxy.ts create mode 100644 apps/api/src/app/proxies/DiscordDataProxy.ts create mode 100644 apps/api/src/app/proxies/TwitterDataProxy.ts create mode 100644 apps/api/src/app/proxies/YoutubeDataProxy.ts create mode 100644 apps/api/src/app/services/AnalyticsService.ts create mode 100644 apps/api/src/app/services/BalancerService.ts create mode 100644 apps/api/src/app/services/BrandService.ts create mode 100644 apps/api/src/app/services/CanvasService.ts create mode 100644 apps/api/src/app/services/ClaimService.ts create mode 100644 apps/api/src/app/services/ContractService.ts create mode 100644 apps/api/src/app/services/DiscordService.ts create mode 100644 apps/api/src/app/services/ERC1155Service.ts create mode 100644 apps/api/src/app/services/ERC20Service.ts create mode 100644 apps/api/src/app/services/ERC721Service.ts create mode 100644 apps/api/src/app/services/GalachainService.ts create mode 100644 apps/api/src/app/services/GitcoinService.ts create mode 100644 apps/api/src/app/services/IPFSService.ts create mode 100644 apps/api/src/app/services/IdentityService.ts create mode 100644 apps/api/src/app/services/ImageService.ts create mode 100644 apps/api/src/app/services/InvoiceService.ts create mode 100644 apps/api/src/app/services/LiquidityService.ts create mode 100644 apps/api/src/app/services/LockService.ts create mode 100644 apps/api/src/app/services/MailService.ts create mode 100644 apps/api/src/app/services/NotificationService.ts create mode 100644 apps/api/src/app/services/ParticipantService.ts create mode 100644 apps/api/src/app/services/PaymentService.ts create mode 100644 apps/api/src/app/services/PointBalanceService.ts create mode 100644 apps/api/src/app/services/PoolService.ts create mode 100644 apps/api/src/app/services/QuestCustomService.ts create mode 100644 apps/api/src/app/services/QuestDailyService.ts create mode 100644 apps/api/src/app/services/QuestDiscordService.ts create mode 100644 apps/api/src/app/services/QuestGitcoinService.ts create mode 100644 apps/api/src/app/services/QuestInviteService.ts create mode 100644 apps/api/src/app/services/QuestService.ts create mode 100644 apps/api/src/app/services/QuestSocialService.ts create mode 100644 apps/api/src/app/services/QuestWeb3Service.ts create mode 100644 apps/api/src/app/services/QuestWebhookService.ts create mode 100644 apps/api/src/app/services/ReCaptchaService.ts create mode 100644 apps/api/src/app/services/RewardCoinService.ts create mode 100644 apps/api/src/app/services/RewardCouponService.ts create mode 100644 apps/api/src/app/services/RewardCustomService.ts create mode 100644 apps/api/src/app/services/RewardDiscordRoleService.ts create mode 100644 apps/api/src/app/services/RewardGalachainService.ts create mode 100644 apps/api/src/app/services/RewardNFTService.ts create mode 100644 apps/api/src/app/services/RewardService.ts create mode 100644 apps/api/src/app/services/SafeService.ts create mode 100644 apps/api/src/app/services/THXService.ts create mode 100644 apps/api/src/app/services/TransactionService.ts create mode 100644 apps/api/src/app/services/TwitterCacheService.ts create mode 100644 apps/api/src/app/services/TwitterQueryService.ts create mode 100644 apps/api/src/app/services/VoteEscrowService.ts create mode 100644 apps/api/src/app/services/WalletService.ts create mode 100644 apps/api/src/app/services/WebhookService.ts create mode 100644 apps/api/src/app/services/index.ts create mode 100644 apps/api/src/app/services/interfaces/IGalaService.ts create mode 100644 apps/api/src/app/services/interfaces/IQuestService.ts create mode 100644 apps/api/src/app/services/interfaces/IRewardService.ts create mode 100644 apps/api/src/app/services/maps/quests.ts create mode 100644 apps/api/src/app/types/augment-express-request.d.ts create mode 100644 apps/api/src/app/types/cors.d.ts create mode 100644 apps/api/src/app/types/jsonwebtoken.d.ts create mode 100644 apps/api/src/app/types/migrate-mongo.d.ts create mode 100644 apps/api/src/app/types/morgan-json.d.ts create mode 100644 apps/api/src/app/types/morgan.d.ts create mode 100644 apps/api/src/app/types/swagger-autogen.d.ts create mode 100644 apps/api/src/app/types/swagger-ui-express.d.ts create mode 100644 apps/api/src/app/util/agenda.ts create mode 100644 apps/api/src/app/util/alchemy.ts create mode 100644 apps/api/src/app/util/auth.ts create mode 100644 apps/api/src/app/util/code.ts create mode 100644 apps/api/src/app/util/condition.ts create mode 100644 apps/api/src/app/util/database.ts create mode 100644 apps/api/src/app/util/date.ts create mode 100644 apps/api/src/app/util/dictionaries.ts create mode 100644 apps/api/src/app/util/discord.ts create mode 100644 apps/api/src/app/util/errors.ts create mode 100644 apps/api/src/app/util/events.ts create mode 100644 apps/api/src/app/util/galachain.ts create mode 100644 apps/api/src/app/util/healthcheck.ts create mode 100644 apps/api/src/app/util/helpers.ts create mode 100644 apps/api/src/app/util/index.ts create mode 100644 apps/api/src/app/util/ip.ts create mode 100644 apps/api/src/app/util/jest/config.ts create mode 100644 apps/api/src/app/util/jest/constants.ts create mode 100644 apps/api/src/app/util/jest/erc1155.ts create mode 100644 apps/api/src/app/util/jest/erc721.ts create mode 100644 apps/api/src/app/util/jest/images.ts create mode 100644 apps/api/src/app/util/jest/jwt.ts create mode 100644 apps/api/src/app/util/jest/mock.ts create mode 100644 apps/api/src/app/util/jest/network.ts create mode 100644 apps/api/src/app/util/jest/test.jpg create mode 100644 apps/api/src/app/util/jwt.ts create mode 100644 apps/api/src/app/util/logger.ts create mode 100644 apps/api/src/app/util/multer.ts create mode 100644 apps/api/src/app/util/network.ts create mode 100644 apps/api/src/app/util/newrelic.ts create mode 100644 apps/api/src/app/util/pagination.ts create mode 100644 apps/api/src/app/util/path.ts create mode 100644 apps/api/src/app/util/polling.ts create mode 100644 apps/api/src/app/util/random.ts create mode 100644 apps/api/src/app/util/ratelimiter.ts create mode 100644 apps/api/src/app/util/s3.ts create mode 100644 apps/api/src/app/util/scopes.ts create mode 100644 apps/api/src/app/util/signingsecret.ts create mode 100644 apps/api/src/app/util/token.ts create mode 100644 apps/api/src/app/util/twitter.ts create mode 100644 apps/api/src/app/util/url.ts create mode 100644 apps/api/src/app/util/uuid.ts create mode 100644 apps/api/src/app/util/validation.ts create mode 100644 apps/api/src/app/util/zip.ts create mode 100644 apps/api/src/assets/.gitkeep create mode 100644 apps/api/src/assets/bg.png create mode 100644 apps/api/src/assets/fa-solid-900.ttf create mode 100644 apps/api/src/assets/logo.png create mode 100644 apps/api/src/assets/qr-logo.jpg create mode 100644 apps/api/src/assets/views/email/base-template.ejs create mode 100644 apps/api/src/discord.ts create mode 100644 apps/api/src/environments/environment.prod.ts create mode 100644 apps/api/src/environments/environment.ts create mode 100644 apps/api/src/main.ts create mode 100644 apps/api/tsconfig.app.json create mode 100644 apps/api/tsconfig.json create mode 100644 apps/api/tsconfig.spec.json create mode 100644 apps/api/webpack.config.js create mode 100644 libs/common/src/lib/chains.ts create mode 100644 libs/common/src/lib/constants.ts create mode 100644 libs/common/src/lib/enums/AccessTokenKind.ts create mode 100644 libs/common/src/lib/enums/AccountPlanType.ts create mode 100644 libs/common/src/lib/enums/AccountVariant.ts create mode 100644 libs/common/src/lib/enums/ChainId.ts create mode 100644 libs/common/src/lib/enums/Collaborator.ts create mode 100644 libs/common/src/lib/enums/DailyRewardClaimState.ts create mode 100644 libs/common/src/lib/enums/ERC1155.ts create mode 100644 libs/common/src/lib/enums/ERC20Type.ts create mode 100644 libs/common/src/lib/enums/ERC721Variant.ts create mode 100644 libs/common/src/lib/enums/Event.ts create mode 100644 libs/common/src/lib/enums/GateVariant.ts create mode 100644 libs/common/src/lib/enums/GrantVariant.ts create mode 100644 libs/common/src/lib/enums/Job.ts create mode 100644 libs/common/src/lib/enums/NFTVariant.ts create mode 100644 libs/common/src/lib/enums/PlatformVariant.ts create mode 100644 libs/common/src/lib/enums/QuestSocialRequirement.ts create mode 100644 libs/common/src/lib/enums/RewardConditionPlatform.ts create mode 100644 libs/common/src/lib/enums/RewardVariant.ts create mode 100644 libs/common/src/lib/enums/Signup.ts create mode 100644 libs/common/src/lib/enums/TransactionState.ts create mode 100644 libs/common/src/lib/enums/TransactionType.ts create mode 100644 libs/common/src/lib/enums/Wallet.ts create mode 100644 libs/common/src/lib/enums/Webhook.ts create mode 100644 libs/common/src/lib/enums/index.ts create mode 100644 libs/common/src/lib/index.ts create mode 100644 libs/common/src/lib/mail.ts create mode 100644 libs/common/src/lib/maps/index.ts create mode 100644 libs/common/src/lib/maps/oauth.ts create mode 100644 libs/common/src/lib/maps/quest.ts create mode 100644 libs/common/src/lib/migrate-mongo.ts create mode 100644 libs/common/src/lib/scss/_alert.scss create mode 100644 libs/common/src/lib/scss/_buttons.scss create mode 100644 libs/common/src/lib/scss/_card.scss create mode 100644 libs/common/src/lib/scss/_contact.scss create mode 100644 libs/common/src/lib/scss/_custom-forms.scss create mode 100644 libs/common/src/lib/scss/_custom-switch.scss create mode 100644 libs/common/src/lib/scss/_dark-mode.scss create mode 100644 libs/common/src/lib/scss/_dropdown.scss create mode 100644 libs/common/src/lib/scss/_faq.scss create mode 100644 libs/common/src/lib/scss/_forms.scss create mode 100644 libs/common/src/lib/scss/_glossary.scss create mode 100644 libs/common/src/lib/scss/_hooper.scss create mode 100644 libs/common/src/lib/scss/_identicon.scss create mode 100644 libs/common/src/lib/scss/_jumbotron.scss create mode 100644 libs/common/src/lib/scss/_modals.scss create mode 100644 libs/common/src/lib/scss/_navbar.scss create mode 100644 libs/common/src/lib/scss/_number.scss create mode 100644 libs/common/src/lib/scss/_popover.scss create mode 100644 libs/common/src/lib/scss/_root.scss create mode 100644 libs/common/src/lib/scss/_sidebar.scss create mode 100644 libs/common/src/lib/scss/_tab.scss create mode 100644 libs/common/src/lib/scss/_team.scss create mode 100644 libs/common/src/lib/scss/_token-distribution.scss create mode 100644 libs/common/src/lib/scss/_utilities.scss create mode 100644 libs/common/src/lib/scss/_variables.scss create mode 100644 libs/common/src/lib/twitter.ts create mode 100644 libs/common/src/lib/types/Account.d.ts create mode 100644 libs/common/src/lib/types/Brand.d.ts create mode 100644 libs/common/src/lib/types/Claim.d.ts create mode 100644 libs/common/src/lib/types/Client.d.ts create mode 100644 libs/common/src/lib/types/Collaborator.d.ts create mode 100644 libs/common/src/lib/types/DiscordBot.d.ts create mode 100644 libs/common/src/lib/types/DiscordRoleReward.d.ts create mode 100644 libs/common/src/lib/types/ERC1155.d.ts create mode 100644 libs/common/src/lib/types/ERC1155Perk.d.ts create mode 100644 libs/common/src/lib/types/ERC1155PerkPayment.d.ts create mode 100644 libs/common/src/lib/types/ERC20.d.ts create mode 100644 libs/common/src/lib/types/ERC721.d.ts create mode 100644 libs/common/src/lib/types/Event.d.ts create mode 100644 libs/common/src/lib/types/Galachain.d.ts create mode 100644 libs/common/src/lib/types/Identity.d.ts create mode 100644 libs/common/src/lib/types/Interaction.d.ts create mode 100644 libs/common/src/lib/types/Invoice.d.ts create mode 100644 libs/common/src/lib/types/Job.d.ts create mode 100644 libs/common/src/lib/types/Notification.d.ts create mode 100644 libs/common/src/lib/types/Pagination.d.ts create mode 100644 libs/common/src/lib/types/Participant.d.ts create mode 100644 libs/common/src/lib/types/Payment.d.ts create mode 100644 libs/common/src/lib/types/Pool.d.ts create mode 100644 libs/common/src/lib/types/Quest.d.ts create mode 100644 libs/common/src/lib/types/QuestCustom.d.ts create mode 100644 libs/common/src/lib/types/QuestCustomEntry.d.ts create mode 100644 libs/common/src/lib/types/QuestDaily.d.ts create mode 100644 libs/common/src/lib/types/QuestDailyEntry.d.ts create mode 100644 libs/common/src/lib/types/QuestGitcoin.d.ts create mode 100644 libs/common/src/lib/types/QuestInvite.d.ts create mode 100644 libs/common/src/lib/types/QuestInviteEntry.d.ts create mode 100644 libs/common/src/lib/types/QuestSocial.d.ts create mode 100644 libs/common/src/lib/types/QuestSocialEntry.d.ts create mode 100644 libs/common/src/lib/types/QuestWebhook.d.ts create mode 100644 libs/common/src/lib/types/QuestWebhookEntry.d.ts create mode 100644 libs/common/src/lib/types/Reward.d.ts create mode 100644 libs/common/src/lib/types/RewardCoin.d.ts create mode 100644 libs/common/src/lib/types/RewardCoinPayment.d.ts create mode 100644 libs/common/src/lib/types/RewardCoupon.d.ts create mode 100644 libs/common/src/lib/types/RewardCustom.d.ts create mode 100644 libs/common/src/lib/types/RewardGalachain.d.ts create mode 100644 libs/common/src/lib/types/RewardGalachainPayment.d.ts create mode 100644 libs/common/src/lib/types/RewardNFT.d.ts create mode 100644 libs/common/src/lib/types/RewardNFTPayment.d.ts create mode 100644 libs/common/src/lib/types/Token.d.ts create mode 100644 libs/common/src/lib/types/Transaction.d.ts create mode 100644 libs/common/src/lib/types/Twitter.d.ts create mode 100644 libs/common/src/lib/types/Wallet.d.ts create mode 100644 libs/common/src/lib/types/Web3Quest.d.ts create mode 100644 libs/common/src/lib/types/Webhook.d.ts create mode 100644 libs/common/src/lib/types/Widget.d.ts create mode 100644 libs/sdk/.eslintrc.json create mode 100644 libs/sdk/README.md create mode 100644 libs/sdk/package.json create mode 100644 libs/sdk/project.json create mode 100644 libs/sdk/src/index.ts create mode 100644 libs/sdk/src/lib/clients/API.ts create mode 100644 libs/sdk/src/lib/clients/Browser.ts create mode 100644 libs/sdk/src/lib/clients/Widget.ts create mode 100644 libs/sdk/src/lib/clients/index.ts create mode 100644 libs/sdk/src/lib/managers/AccountManager.ts create mode 100644 libs/sdk/src/lib/managers/BaseManager.ts create mode 100644 libs/sdk/src/lib/managers/CampaignManager.ts create mode 100644 libs/sdk/src/lib/managers/CouponCodeManager.ts create mode 100644 libs/sdk/src/lib/managers/ERC1155Manager.ts create mode 100644 libs/sdk/src/lib/managers/ERC20Manager.ts create mode 100644 libs/sdk/src/lib/managers/ERC721Manager.ts create mode 100644 libs/sdk/src/lib/managers/EventManager.ts create mode 100644 libs/sdk/src/lib/managers/IdentityManager.ts create mode 100644 libs/sdk/src/lib/managers/OIDCManager.ts create mode 100644 libs/sdk/src/lib/managers/PointBalanceManager.ts create mode 100644 libs/sdk/src/lib/managers/PoolManager.ts create mode 100644 libs/sdk/src/lib/managers/QRCodeManager.ts create mode 100644 libs/sdk/src/lib/managers/QuestManager.ts create mode 100644 libs/sdk/src/lib/managers/RequestManager.ts create mode 100644 libs/sdk/src/lib/managers/RewardManager.ts create mode 100644 libs/sdk/src/lib/types/enums/ChainId.ts create mode 100644 libs/sdk/src/lib/types/enums/Quests.ts create mode 100644 libs/sdk/src/lib/types/enums/index.ts create mode 100644 libs/sdk/src/lib/types/index.ts create mode 100644 libs/sdk/tsconfig.json create mode 100644 libs/sdk/tsconfig.lib.json delete mode 100644 migrations.json create mode 100644 newrelic.js diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..e467a87af --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +REVERSE_PROXY_PORT=8000 +REVERSE_PROXY_UI_PORT=8080 +CFG_VERSION=latest +CGW_VERSION=latest +TXS_VERSION=latest +UI_VERSION=latest +RPC_NODE_URL=http://host.docker.internal:8545 +ETHEREUM_NODE_URL=http://host.docker.internal:8545 \ No newline at end of file diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 000000000..5552bd969 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,41 @@ +MONGODB_URI="mongodb://root:root@localhost:27017/api?authSource=admin&ssl=false" +MONGODB_URI_TEST_OVERRIDE="mongodb://root:root@localhost:27017/api_test?authSource=admin&ssl=false" +MONGODB_NAME="api" +MONGODB_USER="root" +MONGODB_PASSWORD="root" +AUTH_URL="https://local.auth.thx.network" +API_URL="https://localhost:3001" +DASHBOARD_URL="https://localhost:8082" +WALLET_URL="https://localhost:8083" +WIDGET_URL="https://localhost:8080" +HARDHAT_RPC="https://localhost:8547" +HARDHAT_RPC_TEST_OVERRIDE="http://localhost:8545" +POLYGON_RPC="" +ETHEREUM_RPC="" +PRIVATE_KEY="0x873c254263b17925b686f971d7724267710895f1585bb0533db8e693a2af32ff" +INITIAL_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +PORT="3000" +AUTH_CLIENT_ID="ISsh6Xw6wDISihJS2xs2_" +AUTH_CLIENT_SECRET="BnQrAHSb4UDnpR-9klfFAQbZvq_RjVZgLTKwRD3qfOjawX21jrnbBxvSOU84EwqAy1J-fNKvxD2qZen5Gm8jXg" +INFURA_PROJECT_ID="" +INFURA_IPFS_PROJECT_ID="" +INFURA_IPFS_PROJECT_SECRET="" +RATE_LIMIT_REWARD_GIVE="100" +RATE_LIMIT_REWARD_GIVE_WINDOW="900" +MAX_FEE_PER_GAS="400" +POLYGON_NAME="maticdev" +POLYGON_MUMBAI_NAME="mumbaidev" +SAFE_TXS_SERVICE="http://localhost:8000/txs" +HARDHAT_NAME="hardhat" +AWS_ACCESS_KEY_ID="" +AWS_SECRET_ACCESS_KEY="" +AWS_S3_PUBLIC_BUCKET_NAME="local-thx-storage-bucket" +AWS_S3_PUBLIC_BUCKET_REGION="eu-west-3" +AWS_S3_PRIVATE_BUCKET_NAME="local-thx-private-storage-bucket" +AWS_S3_PRIVATE_BUCKET_REGION="eu-west-3" +POLYGON_RELAYER="" +POLYGON_RELAYER_API_KEY="" +POLYGON_RELAYER_API_SECRET="" +LOCAL_CERT="../../certs/localhost.crt" +LOCAL_CERT_KEY="../../certs/localhost.key" +CWD="/usr/src/app/apps/api/src/" \ No newline at end of file diff --git a/apps/api/.eslintrc.json b/apps/api/.eslintrc.json new file mode 100644 index 000000000..5626944bd --- /dev/null +++ b/apps/api/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 000000000..ad1c9101f --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,60 @@ +##################################################################################################### +## Develop stage +##################################################################################################### +FROM node:16-slim AS develop + +WORKDIR /usr/src/app + +ENV NODE_OPTIONS="--max_old_space_size=8192" + +RUN apt-get update \ + && apt-get install -y g++ make python3-pip build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +COPY package.json yarn.lock ./ +RUN yarn +COPY . . + +CMD [ "npx", "nx", "serve", "api" ] + +##################################################################################################### +## Build stage +##################################################################################################### +FROM node:16-slim AS build + +ENV NODE_ENV=production + +WORKDIR /usr/src/app +COPY --from=develop ./usr/src/app/ ./ +RUN npx nx build api --prod +COPY ./newrelic.js ./yarn.lock ./dist/apps/api/ +COPY ./libs/contracts/exports ./dist/apps/api/libs/contracts/exports + +##################################################################################################### +## Production stage +##################################################################################################### +FROM node:16-slim AS production + +ENV NODE_ENV=production + +WORKDIR /usr/src/app +COPY --from=build ./usr/src/app/dist/apps/api/package.json ./usr/src/app/dist/apps/api/yarn.lock ./ + +# Install dependencies and packages +RUN apt-get update \ + && apt-get install -y g++ make python3-pip build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +# Install your application dependencies (assuming it uses Node.js) +RUN yarn + +# Clean up unnecessary packages and files +RUN apt-get purge -y --auto-remove build-essential && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=build ./usr/src/app/dist/apps/api ./ + +CMD [ "main.js" ] \ No newline at end of file diff --git a/apps/api/jest.config.ts b/apps/api/jest.config.ts new file mode 100644 index 000000000..f0a4a6364 --- /dev/null +++ b/apps/api/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'api', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/apps/api', +}; diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 000000000..ee78b15a1 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,20 @@ +{ + "name": "@thxnetwork/api", + "contributors": [ + "Peter Polman ", + "Valeria Grazzini ", + "GarfDev ", + "Bram Rongen ", + "Justina Mary ", + "Evert Kors ", + "jochemvn " + ], + "license": "AGPL-3.0", + "version": "1.52.132", + "scripts": { + "migrate": "yarn run migrate:db && yarn run migrate:contracts", + "migrate:contracts": "node upgradeContractsToLatest.js", + "migrate:db": "node migrate-mongo.js up", + "migrate:db:down": "node migrate-mongo.js down" + } +} diff --git a/apps/api/project.json b/apps/api/project.json new file mode 100644 index 000000000..b6c8c9bcb --- /dev/null +++ b/apps/api/project.json @@ -0,0 +1,111 @@ +{ + "name": "api", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/api/src", + "projectType": "application", + "tags": [], + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "target": "node", + "compiler": "tsc", + "outputPath": "dist/apps/api", + "main": "apps/api/src/main.ts", + "tsConfig": "apps/api/tsconfig.app.json", + "assets": ["apps/api/src/assets", "apps/api/src/app/migrations"], + "webpackConfig": "apps/api/webpack.config.js", + "generatePackageJson": true, + "additionalEntryPoints": [ + { + "entryPath": "apps/api/scripts/upgradeContractsToLatest.ts", + "entryName": "upgradeContractsToLatest" + }, + { + "entryPath": "apps/api/scripts/migrate-mongo.ts", + "entryName": "migrate-mongo" + }, + { + "entryPath": "apps/api/scripts/script.ts", + "entryName": "script" + } + ] + }, + "configurations": { + "development": {}, + "production": { + "optimization": true, + "extractLicenses": true, + "inspect": false + } + } + }, + "serve": { + "executor": "@nx/js:node", + "options": { + "buildTarget": "api:build", + "host": "localhost", + "port": 3000, + "inspect": false, + "watch": true + }, + "configurations": { + "production": { + "buildTarget": "api:build:production" + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/api/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/api/jest.config.ts", + "testTimeout": 60000, + "passWithNoTests": false, + "bail": true, + "runInBand": true, + "logHeapUsage": true + } + }, + "script": { + "dependsOn": ["^build"], + "executor": "nx:run-commands", + "options": { + "command": "node script.js", + "cwd": "dist/apps/api" + } + }, + "migrate-contracts": { + "dependsOn": ["^build"], + "executor": "nx:run-commands", + "options": { + "command": "node upgradeContractsToLatest.js", + "cwd": "dist/apps/api" + } + }, + "migrate-db": { + "dependsOn": ["^build"], + "executor": "nx:run-commands", + "options": { + "command": "node migrate-mongo.js up", + "cwd": "dist/apps/api" + } + }, + "migrate-db-create": { + "executor": "nx:run-commands", + "options": { + "command": "migrate-mongo create -f src/app/config/migrate-mongo-create-only.json", + "cwd": "apps/api" + } + } + } +} diff --git a/apps/api/scripts/migrate-mongo.ts b/apps/api/scripts/migrate-mongo.ts new file mode 100644 index 000000000..eca6d7799 --- /dev/null +++ b/apps/api/scripts/migrate-mongo.ts @@ -0,0 +1,4 @@ +import { migrateMongoScript } from '@thxnetwork/common/index'; +import migrateMongoConfig from '../src/app/config/migrate-mongo'; + +migrateMongoScript(migrateMongoConfig); diff --git a/apps/api/scripts/script.ts b/apps/api/scripts/script.ts new file mode 100644 index 000000000..db419c30c --- /dev/null +++ b/apps/api/scripts/script.ts @@ -0,0 +1,23 @@ +import db from '@thxnetwork/api/util/database'; +import main from './src/veLiquidity'; +// import main from './src/veTransfer'; +// import main from './src/veRewards'; +// import main from './src/time'; +// import main from './src/galachain'; +// import main from './src/sdk'; +// import main from './src/vethx'; +// import main from './src/safe'; +// import main from './src/ipfs'; +// import main from './src/invoices'; +// import main from './src/demo'; +// import main from './src/preview'; +// import main from './src/metamask'; + +db.connect(process.env.MONGODB_URI_PROD); + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/apps/api/scripts/src/demo.ts b/apps/api/scripts/src/demo.ts new file mode 100644 index 000000000..42a652de3 --- /dev/null +++ b/apps/api/scripts/src/demo.ts @@ -0,0 +1,267 @@ +import path from 'path'; +import db from '@thxnetwork/api/util/database'; +import { ChainId, QuestVariant, QuestSocialRequirement, AccessTokenKind } from '@thxnetwork/common/enums'; +import { Widget } from '@thxnetwork/api/models/Widget'; +import { + readCSV, + getYoutubeID, + getTwitterUsername, + createPool, + getSuggestion, + getTwitterTWeet, + getTwitterUser, +} from './utils/index'; +import { RewardCoin } from '@thxnetwork/api/models/RewardCoin'; +import { Collaborator } from '@thxnetwork/api/models/Collaborator'; +import { Pool, QuestDaily, QuestInvite, QuestSocial, QuestCustom } from '@thxnetwork/api/models'; + +const csvFilePath = path.join(__dirname, '../../../', 'quests.csv'); +// const sub = '60a38b36bf804b033c5e3faa'; // Local +const sub = '6074cbdd1459355fae4b6a14'; // Peter +const sub2 = '655d0c5dde9eca4f50999423'; // Prasanth +const sub3 = '627d06fbb0d159d419292240'; // Mieszko +const collaborators = [ + { + sub, + email: 'peter@thx.network', + }, + { + sub: sub2, + email: 'mieszko@thx.network', + }, + { + sub: sub3, + email: 'prasanth@thx.network', + }, +]; + +// const chainId = ChainId.Hardhat; +const chainId = ChainId.Polygon; +// const erc20Id = '64d3a4149f7e6d78c9366982'; // Local +const erc20Id = '6464c665633c1cf385d8cc2b'; // THX Network (POS) on Prod + +export default async function main() { + const start = Date.now(); + const skipped = []; + const results = []; + console.log('Start', new Date()); + + let tokens = 0; + try { + const data: any = await readCSV(csvFilePath); + + for (const sql of data) { + const videoUrl = sql['Youtube Video URL']; + const tweetUrl = sql['Twitter Tweet URL']; + const serverId = sql['Discord Server ID']; + const missingMaterials = sql['Sales Material'] === 'Missing'; + const gameName = sql['Game']; + const gameDomain = sql['Game Domain']; + + if (!missingMaterials || !gameName || !gameDomain) continue; + + let pool = await Pool.findOne({ 'settings.title': gameName }); + console.log('==============='); + console.log('Import: ', gameName, gameDomain); + if (pool) { + await Widget.updateOne({ poolId: pool._id }, { domain: new URL(gameDomain).origin }); + } else { + pool = await createPool(sub, gameName, gameDomain); + } + + await pool.updateOne({ chainId: ChainId.Polygon }); + + const poolId = pool._id; + await pool.updateOne({ chainId }); + + // Remove all existing data + await Promise.all([ + QuestDaily.deleteMany({ poolId }), + QuestInvite.deleteMany({ poolId }), + QuestSocial.deleteMany({ poolId }), + QuestCustom.deleteMany({ poolId }), + RewardCoin.deleteMany({ poolId, erc20Id }), + Collaborator.deleteMany({ poolId }), + ]); + + // Create social quest youtube like + if (videoUrl && videoUrl !== 'N/A') { + const videoId = getYoutubeID(videoUrl); + const socialQuestYoutubeLike = { + poolId, + index: 0, + uuid: db.createUUID(), + title: `Watch & Like`, + description: '', + amount: 75, + kind: AccessTokenKind.Google, + interaction: QuestSocialRequirement.YouTubeLike, + content: videoId, + contentMetadata: JSON.stringify({ videoId }), + isPublished: true, + variant: QuestVariant.YouTube, + }; + await QuestSocial.create(socialQuestYoutubeLike); + } + + // Create social quest twitter retweet + if (tweetUrl && tweetUrl !== 'N/A') { + const username = getTwitterUsername(tweetUrl); + const twitterUser = await getTwitterUser(username); + const socialQuestFollow = { + poolId, + index: 1, + uuid: db.createUUID(), + title: `Follow ${sql['Game']}`, + description: '', + amount: 75, + kind: AccessTokenKind.Twitter, + interaction: QuestSocialRequirement.TwitterFollow, + content: twitterUser.id, + variant: QuestVariant.Twitter, + contentMetadata: JSON.stringify({ + id: twitterUser.id, + name: twitterUser.name, + username: twitterUser.username, + profileImgUrl: twitterUser.profile_image_url, + }), + isPublished: true, + }; + await QuestSocial.create(socialQuestFollow); + + const tweetId = tweetUrl.match(/\/(\d+)(?:\?|$)/)[1]; + const [tweet] = await getTwitterTWeet(tweetId); + const socialQuestRetweet = { + poolId, + uuid: db.createUUID(), + variant: QuestVariant.Twitter, + title: `Boost Tweet!`, + description: '', + amount: 50, + kind: AccessTokenKind.Twitter, + interaction: QuestSocialRequirement.TwitterLikeRetweet, + content: tweetId, + contentMetadata: JSON.stringify({ + url: tweetUrl, + username: twitterUser.username, + text: tweet.text, + }), + isPublished: true, + }; + await QuestSocial.create(socialQuestRetweet); + } + + // Create social quest twitter retweet + if (serverId && serverId !== 'N/A') { + const inviteURL = sql['Discord Invite URL']; + const socialQuestJoin = { + poolId, + variant: QuestVariant.Discord, + uuid: db.createUUID(), + title: `Join ${gameName} Discord`, + description: 'Become a part of our fam!', + amount: 75, + kind: AccessTokenKind.Discord, + interaction: QuestSocialRequirement.DiscordGuildJoined, + content: serverId, + contentMetadata: JSON.stringify({ serverId, inviteURL: inviteURL || undefined }), + isPublished: true, + }; + await QuestSocial.create(socialQuestJoin); + } + + // Create default erc20 rewards + await RewardCoin.create({ + poolId, + uuid: db.createUUID(), + title: `Small bag of $THX`, + description: 'A token of appreciation offered to you by THX Network. Could also be your own token!', + image: 'https://thx-storage-bucket.s3.eu-west-3.amazonaws.com/widget-referral-xmzfznsqschvqxzvgn47qo-xtencq4fmgjg7qgwewmybj-(1)-8EHr7ckbrEZLqUyxqJK1LG.png', + pointPrice: 1000, + limit: 1000, + amount: 10, + erc20Id, + isPublished: true, + }); + + // Create colloborators + for (const c of collaborators) { + await Collaborator.create({ + poolId, + sub: c.sub, + state: 1, + uuid: db.createUUID(), + email: c.email, + }); + } + + // Iterate over available quests and create + for (let i = 1; i < 3; i++) { + const questType = sql[`Q${i} - Type`]; + const points = sql[`Q${i} - Points`]; + const title = sql[`Q${i} - Title`]; + if (!questType || !points || !title) { + console.log(`Incomplete Q${i}!`); + continue; + } + + let titleSuggestion, descriptionSuggestion; + if (['Daily', 'Custom'].includes(questType)) { + titleSuggestion = await getSuggestion(sql[`Q${i} - Title`], 40); + // titleSuggestion = sql[`Q${i} - Title`]; + tokens += titleSuggestion.tokensUsed; + descriptionSuggestion = await getSuggestion(sql[`Q${i} - Description`], 100); + // descriptionSuggestion = sql[`Q${i} - Description`]; + tokens += descriptionSuggestion.tokensUsed; + } + + switch (questType) { + case 'Daily': { + const dailyQuest = { + poolId, + variant: QuestVariant.Daily, + title: titleSuggestion.content, + description: descriptionSuggestion.content, + amounts: [5, 10, 20, 40, 80, 160, 360], + isPublished: true, + }; + await QuestDaily.create(dailyQuest); + console.log(sql[`Q${i} - Type`], titleSuggestion.content, 'quest created!'); + break; + } + case 'Custom': { + const customQuest = { + poolId, + variant: QuestVariant.Custom, + title: titleSuggestion.content, + description: descriptionSuggestion.content, + amount: Number(sql[`Q${i} - Points`]), + limit: 0, + isPublished: true, + }; + await QuestCustom.create(customQuest); + console.log(sql[`Q${i} - Type`], titleSuggestion.content, 'quest created!'); + break; + } + default: { + console.log(sql[`Q${i} - Type`], 'quest skipped...'); + } + } + } + results.push([sql['Game'], `https://dashboard.thx.network/preview/${poolId}`]); + } + } catch (err) { + console.error(err); + } + console.log('==============='); + console.log('COPY BELOW INTO SHEET'); + console.log('==============='); + results.forEach((item) => { + console.log(`${item[0]}\t${item[1]}`); + }); + console.log('==============='); + console.log('Skipped', skipped); + console.log('End', new Date()); + console.log('Duration', Date.now() - start, 'seconds'); + console.log('Tokens Spent', tokens); +} diff --git a/apps/api/scripts/src/galachain.ts b/apps/api/scripts/src/galachain.ts new file mode 100644 index 000000000..a1b34faf8 --- /dev/null +++ b/apps/api/scripts/src/galachain.ts @@ -0,0 +1,83 @@ +import { ethers } from 'ethers'; +import { AllowanceType } from '@gala-chain/api'; +import { GalachainContract, getContract } from '../../src/app/util/galachain'; +import GalachainService from '../../src/app/services/GalachainService'; +import BigNumber from 'bignumber.js'; + +const PRIVATE_KEY_ADMIN = '62172f65ecab45f423f7088128eee8946c5b3c03911cb0b061b1dd9032337271'; +const PRIVATE_KEY_DISTRIBUTOR = '096b2543a26e164e5f8887c737fe31d04734abe657416eacf0b5a52e6c5fa684'; +const PRIVATE_KEY_USER = '97093724e1748ebfa6aa2d2ec4ec68df8678423ab9a12eb2d27ddc74e35e5db9'; + +export default async function main() { + const nftClassKey = { + collection: 'Weapons', + category: 'Blades', + type: 'none', + additionalKey: 'none', + }; + // Define coin class key + const tokenInfo = { + decimals: 0, + tokenClass: nftClassKey, + name: 'Sting', + symbol: 'WBSting', + description: 'This collection holds weapons of any sort!', + image: 'https://pbs.twimg.com/profile_images/1640708099177877505/4U-ya--t_400x400.jpg', + isNonFungible: true, + maxSupply: new BigNumber(100), + }; + const profileContract = getContract(GalachainContract.PublicKeyContract); + const tokenContract = getContract(GalachainContract.GalaChainToken); + + // Generate random wallet + const walletAdmin = new ethers.Wallet(PRIVATE_KEY_ADMIN); + console.log({ walletAdmin }); + + const walletDistributor = new ethers.Wallet(PRIVATE_KEY_DISTRIBUTOR); + console.log({ walletDistributor }); + + const walletUser = new ethers.Wallet(PRIVATE_KEY_USER); + console.log({ walletUser }); + + // Register distributor + const distributor = await GalachainService.registerUser( + profileContract, + walletDistributor.publicKey, + PRIVATE_KEY_ADMIN, + ); + console.log(distributor); + + // Register user + const user = await GalachainService.registerUser(profileContract, walletUser.publicKey, PRIVATE_KEY_ADMIN); + console.log(user); + + // Admin creates an NFT + const nft = await GalachainService.create(tokenContract, tokenInfo, nftClassKey, PRIVATE_KEY_ADMIN); + console.log(nft); + + // Approve minting of maxsupply for admin + const result = await GalachainService.approve( + tokenContract, + nftClassKey, + walletDistributor.address, + 100, + AllowanceType.Mint, + PRIVATE_KEY_ADMIN, + ); + console.log(result); + + // Distributor mints 5 tokens + await GalachainService.mint(tokenContract, nftClassKey, walletDistributor.address, 5, PRIVATE_KEY_DISTRIBUTOR); + + // Get balance of tokens for distributor + const balances = (await GalachainService.balanceOf(tokenContract, nftClassKey, PRIVATE_KEY_DISTRIBUTOR)) as { + quantity: number; + }[]; + console.log(balances); + + // Balance of the user + const [balanceUser] = (await GalachainService.balanceOf(tokenContract, nftClassKey, PRIVATE_KEY_USER)) as { + quantity: number; + }[]; + console.log(balanceUser); +} diff --git a/apps/api/scripts/src/invoices.ts b/apps/api/scripts/src/invoices.ts new file mode 100644 index 000000000..ae4d752b7 --- /dev/null +++ b/apps/api/scripts/src/invoices.ts @@ -0,0 +1,16 @@ +import InvoiceService from '@thxnetwork/api/services/InvoiceService'; +import { startOfMonth, endOfMonth, addHours, subMonths } from 'date-fns'; + +export default async function main() { + const currentDate = subMonths(new Date(), 0); + const invoicePeriodstartDate = startOfMonth(currentDate); + const invoicePeriodEndDate = endOfMonth(currentDate); + + // Account for UTC + 2 timezone offset + const offset = 2; + + await InvoiceService.upsertInvoices( + addHours(invoicePeriodstartDate, offset), + addHours(invoicePeriodEndDate, offset), + ); +} diff --git a/apps/api/scripts/src/ipfs.ts b/apps/api/scripts/src/ipfs.ts new file mode 100644 index 000000000..d7b3d08f6 --- /dev/null +++ b/apps/api/scripts/src/ipfs.ts @@ -0,0 +1,29 @@ +import { ERC721 } from '@thxnetwork/api/models/ERC721'; +import { ERC721Metadata } from '@thxnetwork/api/models/ERC721Metadata'; +import pinataSDK from '@pinata/sdk'; +import axios from 'axios'; + +const pinata = new pinataSDK({ pinataJWTKey: process.env.PINATA_API_JWT }); + +class PinataIPFS { + static async addURL(url: string) { + const response = await axios.get(url, { responseType: 'stream' }); + const urlParts = url.split('/'); + const name = urlParts[urlParts.length - 1]; + const { IpfsHash } = await pinata.pinFileToIPFS(response.data, { + pinataMetadata: { name }, + pinataOptions: { cidVersion: 0 }, + }); + return IpfsHash; + } +} + +export default async function main() { + const nft = await ERC721.findById('64c8f15e01506efa24c1c72c'); + const metadataList = await ERC721Metadata.find({ erc721Id: nft._id }); + for (const metadata of metadataList) { + const imgCid = await PinataIPFS.addURL(metadata.imageUrl); + const metadataCid = await PinataIPFS.addURL('https://api.thx.network/v1/metadata/' + String(metadata._id)); + console.log(metadata.name, String(metadata._id), imgCid, metadataCid); + } +} diff --git a/apps/api/scripts/src/metamask.ts b/apps/api/scripts/src/metamask.ts new file mode 100644 index 000000000..7700240d4 --- /dev/null +++ b/apps/api/scripts/src/metamask.ts @@ -0,0 +1,136 @@ +import path from 'path'; +import fs from 'fs'; +import { MongoClient, Db } from 'mongodb'; +import { Wallet } from '@thxnetwork/api/models/Wallet'; +import { QuestSocialEntry } from '@thxnetwork/api/models/QuestSocialEntry'; +import { + ERC1155Token, + ERC20Token, + ERC721Token, + Participant, + Pool, + QuestCustomEntry, + QuestDailyEntry, + QuestGitcoinEntry, + QuestWeb3Entry, + RewardCoinPayment, + RewardCouponPayment, + RewardCustomPayment, + RewardDiscordRolePayment, + RewardNFTPayment, +} from '@thxnetwork/api/models'; + +export default async function main() { + const filePath = path.join(__dirname, '../../../metamask-accounts.csv'); + const client = new MongoClient(process.env.MONGODB_URI_AUTH_PROD); + + await client.connect(); + + const db: Db = client.db('auth-prod'); + const accounts = await db.collection('accounts').find({ variant: 4 }).toArray(); + const subs = accounts.map(({ _id }) => String(_id)); + const wallets = await Wallet.find({ + sub: { $in: subs }, + address: { $exists: true }, + $and: [{ version: { $exists: false } }, { safeVersion: { $exists: false } }], + }); + const participants = await Participant.find({ sub: { $in: subs } }); + const poolIds = participants.map((p) => p.poolId); + const pools = await Pool.find({ _id: { $in: poolIds } }); + + const csvData = await Promise.all( + accounts.map(async (account) => { + const sub = String(account._id); + const w = wallets.find((p) => p.sub === sub); + const p = participants.find((p) => p.sub === sub); + const pool = p && pools.find((pool) => String(pool._id) === p.poolId); + + const [ + dailyCount, + socialCount, + customCount, + web3Count, + gitcoinCount, + erc20Count, + erc721Count, + erc1155Count, + coinCount, + nftCount, + discordCount, + customrewardCount, + couponCount, + ] = await Promise.all( + [ + QuestDailyEntry, + QuestSocialEntry, + QuestCustomEntry, + QuestWeb3Entry, + QuestGitcoinEntry, + // Coins + ERC20Token, + // NFT + ERC721Token, + ERC1155Token, + // RewardPayments + RewardCoinPayment, + RewardNFTPayment, + RewardDiscordRolePayment, + RewardCustomPayment, + RewardCouponPayment, + ].map((Model) => Model.countDocuments({ sub })), + ); + + return [ + sub, + pool && pool.settings && pool.settings.title, + p && p.balance, + p && p.score, + p && p.questEntryCount, + p && p.updatedAt, + w && w.address, + dailyCount, + socialCount, + customCount, + web3Count, + gitcoinCount, + erc20Count, + erc721Count, + erc1155Count, + coinCount, + nftCount, + discordCount, + customrewardCount, + couponCount, + ].join(','); + }), + ); + + csvData.push( + [ + 'sub', + 'campaign', + 'balance', + 'score', + 'questEntryCount', + 'updatedAt', + 'address', + 'dailyCount', + 'socialCount', + 'customCount', + 'web3Count', + 'gitcoinCoun', + 'erc20Count', + 'erc721Count', + 'erc1155Count', + 'coinCount', + 'nftCount', + 'discordCount', + 'customrewardCount', + 'couponCount', + ].join(','), + ); + + fs.writeFileSync(filePath, csvData.reverse().join('\n'), 'utf8'); +} +// This query finds all wallets that have an address and do not have a version nor safeVersion field. Indicating they are metamask wallets +// { $and: [{ version: { $exists: false} }, { safeVersion: { $exists: false } }], sub: { $exists: true }, poolId: { $exists: true } } diff --git a/apps/api/scripts/src/preview.ts b/apps/api/scripts/src/preview.ts new file mode 100644 index 000000000..5b15277d8 --- /dev/null +++ b/apps/api/scripts/src/preview.ts @@ -0,0 +1,177 @@ +import puppeteer from 'puppeteer'; +import fs from 'fs'; +import db from '@thxnetwork/api/util/database'; +import { Brand, Widget } from '@thxnetwork/api/models'; +import { createCanvas, loadImage, registerFont } from 'canvas'; +import { assetsPath } from '@thxnetwork/api/util/path'; +import path from 'path'; +import CanvasService from '@thxnetwork/api/services/CanvasService'; + +// Provide before running +const poolIds = ['660f101c4a0130f6f8315762', '660f10e4e298a7a04bbb35ae']; + +// Load on boot as registration on runtime results in font not being loaded in time +const fontPath = path.resolve(assetsPath, 'fa-solid-900.ttf'); +const family = 'Font Awesome 5 Pro Solid'; +const defaultBackgroundImgPath = path.resolve(assetsPath, 'bg.png'); +const defaultLogoImgPath = path.resolve(assetsPath, 'logo.png'); + +// ENV +// const widgetBaseUrl = 'https://dev-app.thx.network'; +const widgetBaseUrl = 'https://app.thx.network'; + +registerFont(fontPath, { family, style: 'normal', weight: '900' }); + +// db.connect(process.env.MONGODB_URI); +// db.connect(process.env.MONGODB_URI_DEV); +db.connect(process.env.MONGODB_URI_PROD); + +async function createCampaignWidgetPreviewImage({ poolId, logoImgUrl, backgroundImgUrl }: TBrand) { + const widget = await Widget.findOne({ poolId }); + if (!widget) return; + const theme = JSON.parse(widget.theme); + + const rightOffset = 20; + const bottomOffset = 90; + const widgetHeight = 700; + const widgetWidth = 400; + + // Get screenshot image + const widgetUrl = `${widgetBaseUrl}/c/${poolId}/quests`; + const fileName = `${poolId}.jpg`; + + // Can not use asset path here on runtime + const outputPath = path.resolve(__dirname, fileName); + await captureScreenshot(widgetUrl, outputPath, widgetWidth, widgetHeight); + + // Read screenshot from disk + const file = fs.readFileSync(outputPath); + if (!file) throw new Error('Screenshot failed'); + + // Load the base64 image data into an Image object + const bg = await loadImage(backgroundImgUrl || defaultBackgroundImgPath); + const logo = await loadImage(logoImgUrl || defaultLogoImgPath); + const screenshot = await loadImage(file); + + // Create a canvas with the desired dimensions + const canvasHeight = widgetHeight + bottomOffset + rightOffset; // 810 + const canvasWidth = Math.floor((canvasHeight / 9) * 16); // 1440 + const canvas = createCanvas(canvasWidth, canvasHeight); + const ctx = canvas.getContext('2d'); + + // Draw the loaded image onto the canvas + CanvasService.drawImageBg(canvas, ctx, bg); + + // Draw the logo + const logoRatio = logo.width / logo.height; + const logoWidth = 200; + const logoHeight = logoWidth / logoRatio; + + ctx.drawImage(logo, canvasWidth / 2 - logoWidth / 2, canvasHeight / 2 - logoHeight / 2, logoWidth, logoHeight); + + const launcherRadius = 30; + const launcherCenterOffset = 50; + + // Draw the launcher circle + ctx.beginPath(); + ctx.arc(canvasWidth - launcherCenterOffset, canvasHeight - launcherCenterOffset, launcherRadius, 0, 2 * Math.PI); + ctx.fillStyle = theme.elements.launcherBg.color; + ctx.fill(); + + const notificationRadius = 10; + const notificationX = canvasWidth - launcherCenterOffset - launcherRadius / 2 - notificationRadius / 2; + const notificationY = canvasHeight - launcherCenterOffset - launcherRadius / 2 - notificationRadius / 2; + + // Draw the launcher icon + const fontSizeIcon = 20; + ctx.font = `900 ${fontSizeIcon}px "${family}"`; + ctx.fillStyle = theme.elements.launcherIcon.color; //; + ctx.fillText( + `\uf06b`, + canvasWidth - launcherCenterOffset - fontSizeIcon / 2, + canvasHeight - launcherCenterOffset + fontSizeIcon / 3, + ); + + // Draw the notification circle + ctx.beginPath(); + ctx.arc(notificationX, notificationY, notificationRadius, 0, 2 * Math.PI); + ctx.fillStyle = 'red'; + ctx.fill(); + + // Draw the notificition counter + const fontSizeNotification = 16; + ctx.font = `bold normal ${fontSizeNotification}px "Arial"`; + ctx.fillStyle = 'white'; + ctx.fillText('3', notificationX - notificationRadius / 2, notificationY + notificationRadius / 2); + + // Draw the widget screenshot + const borderRadius = 10; + const widgetX = canvasWidth - widgetWidth - rightOffset; + const widgetY = canvasHeight - widgetHeight - bottomOffset; + + // Round the borders by clipping + drawImageRounded(ctx, widgetX, widgetY, widgetWidth, widgetHeight, borderRadius); + ctx.clip(); + ctx.drawImage(screenshot, widgetX, widgetY, widgetWidth, widgetHeight); + + // Convert the canvas content to a buffer + // const dataUrl = canvas.toDataURL('image/png'); + const buffer = canvas.toBuffer('image/png'); + + return buffer; +} + +export async function captureScreenshot(url, outputFileName, width, height) { + const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)); + + const browser = await puppeteer.launch({ headless: 'new' }); + const page = await browser.newPage(); + + await page.setViewport({ width, height }); + await page.goto(url); + + // Collapse CSS animation needs to finish + await delay(3000); + + await page.screenshot({ path: outputFileName }); + + await browser.close(); +} + +function drawImageRounded(ctx, x, y, width, height, radius) { + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); +} + +export default async function main() { + const start = Date.now(); + + console.log('Start', new Date()); + const brands = await Brand.find({ poolId: { $in: poolIds } }); + for (const index in brands) { + try { + const brand = brands[index]; + const previewFile = await createCampaignWidgetPreviewImage(brand); + if (!previewFile) continue; + // Write the image buffer data to the file + const output = path.join('/Users/peterpolman/Desktop/previews', `${brand.poolId}.png`); + fs.writeFileSync(output, previewFile); + + console.log(`${Number(index) + 1}/${brands.length} ${brand.poolId}`); + } catch (error) { + console.error(brands[index].poolId, error); + } + } + + console.log('End', new Date()); + console.log('Duration', Date.now() - start, 'seconds'); +} diff --git a/apps/api/scripts/src/safe.ts b/apps/api/scripts/src/safe.ts new file mode 100644 index 000000000..883ead62c --- /dev/null +++ b/apps/api/scripts/src/safe.ts @@ -0,0 +1,31 @@ +import { safeVersion } from '@thxnetwork/api/services/ContractService'; +import { toChecksumAddress } from 'web3-utils'; +import { Wallet } from '@thxnetwork/api/models/Wallet'; +import { ChainId } from '@thxnetwork/common/enums'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { SafeFactory } from '@safe-global/protocol-kit'; + +export default async function main() { + const SAFE = toChecksumAddress(''); // Provide values + const RELAYER = toChecksumAddress(''); // Provide values + const ACCOUNT = toChecksumAddress(''); // Provide values + const wallet = await Wallet.findOne({ + address: SAFE, + chainId: ChainId.Polygon, + }); + if (SAFE !== wallet.address) throw new Error('Provided address does not equal Safe address.'); + + const { ethAdapter } = getProvider(ChainId.Polygon); + const safeFactory = await SafeFactory.create({ + safeVersion, + ethAdapter, + }); + const safeAccountConfig = { + owners: [RELAYER, ACCOUNT], + threshold: 2, + }; + const safeAddress = await safeFactory.predictSafeAddress(safeAccountConfig); + console.log(safeAddress); + + await safeFactory.deploySafe({ safeAccountConfig, options: { gasLimit: '3000000' } }); +} diff --git a/apps/api/scripts/src/sdk.ts b/apps/api/scripts/src/sdk.ts new file mode 100644 index 000000000..e3b278c01 --- /dev/null +++ b/apps/api/scripts/src/sdk.ts @@ -0,0 +1,12 @@ +import { THXAPIClient } from '@thxnetwork/sdk/clients'; +import { API_URL, AUTH_URL } from '@thxnetwork/api/config/secrets'; + +export default async function main() { + const thx = new THXAPIClient({ + authUrl: AUTH_URL, + apiUrl: API_URL, + clientId: 'BitG_fGJI5k70kQgEeyID', + clientSecret: 'pniCrGc49hb_l18_MrpahhJC8SexAV1nHE9RR9CkZA2qA_YbRmJd1hSHl5fcpJA1ngmRwuoys47JfLtYJDlSgA', + }); + await thx.events.create({ event: 'test', identity: '4de81b20-c71d-11ee-ac82-a970a9e4ebc4' }); +} diff --git a/apps/api/scripts/src/time.ts b/apps/api/scripts/src/time.ts new file mode 100644 index 000000000..0c2f65ad5 --- /dev/null +++ b/apps/api/scripts/src/time.ts @@ -0,0 +1,14 @@ +import { ethers } from 'ethers'; + +export async function increaseBlockTime(provider, seconds) { + await provider.send('evm_increaseTime', [seconds]); + await provider.send('evm_mine', []); +} + +export default async function main() { + const HARDHAT_RPC = 'http://127.0.0.1:8545/'; + const hardhatProvider = new ethers.providers.JsonRpcProvider(HARDHAT_RPC); + + // Travel past first week else this throws "Reward distribution has not started yet" + await increaseBlockTime(hardhatProvider, 60 * 60 * 24 * 7); +} diff --git a/apps/api/scripts/src/utils/index.ts b/apps/api/scripts/src/utils/index.ts new file mode 100644 index 000000000..4e3d9cffb --- /dev/null +++ b/apps/api/scripts/src/utils/index.ts @@ -0,0 +1,98 @@ +import fs from 'fs'; +import OpenAI from 'openai'; +import csvParser from 'csv-parser'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import { DEFAULT_COLORS, DEFAULT_ELEMENTS } from '@thxnetwork/common/constants'; +import { Widget } from '@thxnetwork/api/models/Widget'; +import { v4 } from 'uuid'; +import { twitterClient } from '@thxnetwork/api/util/twitter'; + +async function readCSV(csvFilePath: string) { + const data: any = []; + + return new Promise((resolve, reject) => { + fs.createReadStream(csvFilePath) + .pipe(csvParser()) + .on('data', (row) => { + data.push(row); + }) + .on('end', () => { + console.log('CSV data read successfully:'); + resolve(data); + }) + .on('error', (err) => { + reject(new Error('Error while reading CSV: ' + err.message)); + }); + }); +} + +function getYoutubeID(url) { + if (url && url.toLowerCase().includes('shorts')) return; + + const result = /^https?:\/\/(www\.)?youtu\.be/.test(url) + ? url.replace(/^https?:\/\/(www\.)?youtu\.be\/([\w-]{11}).*/, '$2') + : url.replace(/.*\?v=([\w-]{11}).*/, '$1'); + + return result; +} + +function getTwitterUsername(url: string) { + return url.split('/')[3]; +} + +async function createPool(sub: string, title: string, gameUrl: string) { + const pool = await PoolService.deploy(sub, title); + + await Widget.create({ + uuid: v4(), + poolId: pool._id, + align: 'right', + message: 'Hi there!👋 Click me to earn rewards with quests...', + domain: new URL(gameUrl).origin, + theme: JSON.stringify({ elements: DEFAULT_ELEMENTS, colors: DEFAULT_COLORS }), + }); + + return pool; +} + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}); + +async function getSuggestion(content: string, length: number) { + if (!content) return; + + const prompt = `You are a content writer for games. Please rephrase this text in a short, engaging and active form. Apply a maximum character length of ${length} characters:`; + const data = await openai.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: prompt + content }], + }); + + return { + content: data.choices[0].message.content, + tokensUsed: Number(data.usage.total_tokens), + }; +} +async function getTwitterTWeet(tweetId: string) { + const { data } = await twitterClient({ + method: 'GET', + url: `/tweets`, + params: { + ids: tweetId, + expansions: 'author_id', + }, + }); + return data.data; +} +async function getTwitterUser(username: string) { + const { data } = await twitterClient({ + method: 'GET', + url: `/users/by/username/${username}`, + params: { + 'user.fields': 'profile_image_url', + }, + }); + return data.data; +} + +export { readCSV, getYoutubeID, getTwitterUsername, createPool, getSuggestion, getTwitterTWeet, getTwitterUser }; diff --git a/apps/api/scripts/src/veLiquidity.ts b/apps/api/scripts/src/veLiquidity.ts new file mode 100644 index 000000000..347d34db1 --- /dev/null +++ b/apps/api/scripts/src/veLiquidity.ts @@ -0,0 +1,48 @@ +import { ethers } from 'ethers'; +import { PRIVATE_KEY } from '@thxnetwork/api/config/secrets'; +import { contractArtifacts, contractNetworks } from '@thxnetwork/api/contracts'; +import { ChainId } from '@thxnetwork/common/enums'; +import { parseUnits } from 'ethers/lib/utils'; +import TransactionService from '@thxnetwork/api/services/TransactionService'; + +async function increaseBlockTime(provider, seconds) { + await provider.send('evm_increaseTime', [seconds]); + await provider.send('evm_mine', []); +} + +export default async function main() { + const HARDHAT_RPC = 'http://127.0.0.1:8545/'; + const hardhatProvider = new ethers.providers.JsonRpcProvider(HARDHAT_RPC); + // const TO = '0x029E2d4D2b6938c92c48dbf422a4e500425a08D8'; + const TO = '0xaf9d56684466fcFcEA0a2B7fC137AB864d642946'; + // const TO = '0x7b8fc09eb5D80eadA6AE74b112463eA006DC25B5'; + const AMOUNT_USDC = parseUnits('12300', 6).toString(); + const AMOUNT_THX = parseUnits('45600', 18).toString(); + const chainId = ChainId.Hardhat; + const signer = new ethers.Wallet(PRIVATE_KEY, hardhatProvider) as unknown as ethers.Signer; + + const usdc = new ethers.Contract(contractNetworks[chainId].USDC, contractArtifacts['USDC'].abi, signer); + await usdc.transfer(TO, AMOUNT_USDC); + + const thx = new ethers.Contract(contractNetworks[chainId].THX, contractArtifacts['THX'].abi, signer); + await thx.transfer(TO, AMOUNT_THX); + + // Increase time till past veTHX reward distribution start time + await increaseBlockTime(hardhatProvider, 60 * 60 * 24 * 7); + + // const usdcPerMonthInWei = ethers.utils.parseUnits('146700', 6); + // const secondsPerMonth = 30 * 24 * 60 * 60; + // const usdcPerSecond = usdcPerMonthInWei.div(secondsPerMonth); + // const splitter = new ethers.Contract( + // contractNetworks[chainId].THXPaymentSplitter, + // contractArtifacts['THXPaymentSplitter'].abi, + // signer, + // ); + // await splitter.setRate(TO, usdcPerSecond); + // const rate = await splitter.rates(TO); + // console.log(usdcPerSecond.toString(), rate.toString()); + + // await usdc.approve(splitter.address, usdcPerMonthInWei); + // await splitter.deposit(TO, usdcPerMonthInWei); + // console.log((await splitter.balanceOf(TO)).toString()); +} diff --git a/apps/api/scripts/src/veRewards.ts b/apps/api/scripts/src/veRewards.ts new file mode 100644 index 000000000..c1e64e86a --- /dev/null +++ b/apps/api/scripts/src/veRewards.ts @@ -0,0 +1,48 @@ +import { ethers } from 'ethers'; +import { PRIVATE_KEY } from '@thxnetwork/api/config/secrets'; +import { contractArtifacts, contractNetworks } from '@thxnetwork/api/contracts'; +import { ChainId } from '@thxnetwork/common/enums'; +import { parseUnits } from 'ethers/lib/utils'; +import { increaseBlockTime } from './time'; + +export default async function main() { + const HARDHAT_RPC = 'http://127.0.0.1:8545/'; + const hardhatProvider = new ethers.providers.JsonRpcProvider(HARDHAT_RPC); + const chainId = ChainId.Hardhat; + const signer = new ethers.Wallet(PRIVATE_KEY, hardhatProvider) as unknown as ethers.Signer; + const bpt = new ethers.Contract(contractNetworks[chainId].BPT, contractArtifacts['BPT'].abi, signer); + const bal = new ethers.Contract(contractNetworks[chainId].BAL, contractArtifacts['BAL'].abi, signer); + const rewardFaucet = new ethers.Contract( + contractNetworks[chainId].RewardFaucet, + contractArtifacts['RewardFaucet'].abi, + signer, + ); + const AMOUNTBAL = parseUnits('100').toString(); + const AMOUNTBPT = parseUnits('1000').toString(); + + // Travel past reward distribution start time + await increaseBlockTime(hardhatProvider, 60 * 60 * 24 * 7); + + // Deposit reward tokens into rdthx + await bal.approve(rewardFaucet.address, AMOUNTBAL); + await bpt.approve(rewardFaucet.address, AMOUNTBPT); + + // // Claim all pending BAL rewards and prepare for distriubtion + // // await ve.claimExternalRewards(); + // // Mock externalRewards by transfering directly into rd + // await bal.transfer(rd.address, AMOUNT); + + // Make sure to redistribute past rewards before depositing + // await rf.distributePastRewards(bpt.address); + // await rf.distributePastRewards(bal.address); + + // Spread the amount evenly over 4 weeks from the current block + // Can only run after reward distribution has started + const tx1 = await rewardFaucet.depositEqualWeeksPeriod(bal.address, AMOUNTBAL, '4'); + console.log(await tx1.wait()); + const tx2 = await rewardFaucet.depositEqualWeeksPeriod(bpt.address, AMOUNTBPT, '4'); + console.log(await tx2.wait()); + + console.log(await rewardFaucet.getUpcomingRewardsForNWeeks(bal.address, '4')); + console.log(await rewardFaucet.getUpcomingRewardsForNWeeks(bpt.address, '4')); +} diff --git a/apps/api/scripts/src/veTransfer.ts b/apps/api/scripts/src/veTransfer.ts new file mode 100644 index 000000000..5eafec36c --- /dev/null +++ b/apps/api/scripts/src/veTransfer.ts @@ -0,0 +1,19 @@ +import { ethers } from 'ethers'; +import { PRIVATE_KEY } from '@thxnetwork/api/config/secrets'; +import { contractArtifacts, contractNetworks } from '@thxnetwork/api/contracts'; +import { ChainId } from '@thxnetwork/common/enums'; +import { parseUnits } from 'ethers/lib/utils'; + +export default async function main() { + const HARDHAT_RPC = 'http://127.0.0.1:8545/'; + const hardhatProvider = new ethers.providers.JsonRpcProvider(HARDHAT_RPC); + // const TO = '0x9013ae40FCd95D46BA4902F3974A71C40793680B'; + const TO = '0xaf9d56684466fcFcEA0a2B7fC137AB864d642946'; + + const AMOUNT = parseUnits('10000').toString(); + const chainId = ChainId.Hardhat; + const signer = new ethers.Wallet(PRIVATE_KEY, hardhatProvider) as unknown as ethers.Signer; + const bpt = new ethers.Contract(contractNetworks[chainId].BPT, contractArtifacts['BPT'].abi, signer); + + await bpt.transfer(TO, AMOUNT); +} diff --git a/apps/api/scripts/upgradeContractsToLatest.ts b/apps/api/scripts/upgradeContractsToLatest.ts new file mode 100644 index 000000000..ecc0bdb7d --- /dev/null +++ b/apps/api/scripts/upgradeContractsToLatest.ts @@ -0,0 +1,20 @@ +import db from '../src/app/util/database'; +import { MONGODB_URI } from '../src/app/config/secrets'; + +db.connect(MONGODB_URI); + +async function main() { + const startTime = Date.now(); + // Add prerelease jobs here + // ... + + const endTime = Date.now(); + console.log(`🔔 Duration: ${Math.floor((endTime - startTime) / 1000)} seconds`); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/apps/api/sonar-project.properties b/apps/api/sonar-project.properties new file mode 100644 index 000000000..ffea24075 --- /dev/null +++ b/apps/api/sonar-project.properties @@ -0,0 +1,6 @@ +sonar.sources=. +sonar.tests="src" +sonar.test.inclusions="src/**/*.test.ts" +sonar.projectKey=thxnetwork_api +sonar.organization=thxnetwork +sonar.javascript.lcov.reportPaths=../../coverage/apps/api/lcov.info diff --git a/apps/api/src/app/config/migrate-mongo-create-only.json b/apps/api/src/app/config/migrate-mongo-create-only.json new file mode 100644 index 000000000..f6776da66 --- /dev/null +++ b/apps/api/src/app/config/migrate-mongo-create-only.json @@ -0,0 +1,4 @@ +{ + "migrationsDir": "src/app/migrations", + "moduleSystem": "commonjs" +} diff --git a/apps/api/src/app/config/migrate-mongo.ts b/apps/api/src/app/config/migrate-mongo.ts new file mode 100644 index 000000000..d98ee850c --- /dev/null +++ b/apps/api/src/app/config/migrate-mongo.ts @@ -0,0 +1,13 @@ +import path from 'path'; +import { MONGODB_URI } from '@thxnetwork/api/config/secrets'; + +export default { + migrationFileExtension: '.js', + mongodb: { + url: MONGODB_URI, + }, + migrationsDir: path.join(path.resolve(__dirname), 'app/migrations'), + changelogCollectionName: 'changelog', + useFileHash: false, + moduleSystem: 'commonjs', +}; diff --git a/apps/api/src/app/config/secrets.ts b/apps/api/src/app/config/secrets.ts new file mode 100644 index 000000000..cfd49366f --- /dev/null +++ b/apps/api/src/app/config/secrets.ts @@ -0,0 +1,121 @@ +import { Speed } from '@openzeppelin/defender-relay-client'; +import path from 'path'; + +const required = [ + 'AUTH_URL', + 'API_URL', + 'DASHBOARD_URL', + 'MONGODB_URI', + 'PORT', + 'AUTH_CLIENT_ID', + 'AUTH_CLIENT_SECRET', + 'INITIAL_ACCESS_TOKEN', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_S3_PUBLIC_BUCKET_NAME', + 'AWS_S3_PUBLIC_BUCKET_REGION', + 'AWS_S3_PRIVATE_BUCKET_NAME', + 'AWS_S3_PRIVATE_BUCKET_REGION', + 'SAFE_TXS_SERVICE', + 'CWD', +]; + +if (process.env.NODE_ENV === 'production') { + required.push( + ...[ + 'GCLOUD_RECAPTCHA_API_KEY', + 'POLYGON_RPC', + 'POLYGON_NAME', + 'POLYGON_RELAYER', + 'POLYGON_RELAYER_API_KEY', + 'POLYGON_RELAYER_API_SECRET', + 'INFURA_IPFS_PROJECT_ID', + 'INFURA_IPFS_PROJECT_SECRET', + 'RELAYER_SPEED', + 'TWITTER_API_TOKEN', + ], + ); +} else if (process.env.NODE_ENV === 'development') { + required.push(...['PRIVATE_KEY', 'HARDHAT_RPC', 'LOCAL_CERT', 'LOCAL_CERT_KEY', 'TWITTER_API_TOKEN']); +} + +required.forEach((value: string) => { + if (!process.env[value]) { + console.log(`Set ${value} environment variable.`); + process.exit(1); + } +}); + +// This allows you to use a single .env file with both regular and test configuration. This allows for an +// easy to use setup locally without having hardcoded credentials during test runs. +if (process.env.NODE_ENV === 'test') { + if (process.env.MONGODB_URI_TEST_OVERRIDE !== undefined) + process.env.MONGODB_URI = process.env.MONGODB_URI_TEST_OVERRIDE; + if (process.env.HARDHAT_RPC_TEST_OVERRIDE) process.env.HARDHAT_RPC = process.env.HARDHAT_RPC_TEST_OVERRIDE; +} + +export const VERSION = 'v1'; +export const CWD = process.env.CWD || path.resolve(__dirname, '../../../apps/api/src'); +export const NODE_ENV = process.env.NODE_ENV || 'development'; +export const AUTH_URL = process.env.AUTH_URL || ''; +export const API_URL = process.env.API_URL || ''; +export const WALLET_URL = process.env.WALLET_URL || ''; +export const DASHBOARD_URL = process.env.DASHBOARD_URL || ''; +export const WIDGET_URL = process.env.WIDGET_URL || ''; +export const PUBLIC_URL = process.env.PUBLIC_URL || ''; +export const HARDHAT_RPC = process.env.HARDHAT_RPC || ''; +export const POLYGON_RPC = process.env.POLYGON_RPC || 'https://rpc.ankr.com/polygon'; +export const ETHEREUM_RPC = process.env.ETHEREUM_RPC || 'https://rpc.ankr.com/eth'; +export const MONGODB_URI = String(process.env.MONGODB_URI) || ''; +export const PRIVATE_KEY = process.env.PRIVATE_KEY || ''; +export const PORT = process.env.PORT || ''; +export const AUTH_CLIENT_ID = process.env.AUTH_CLIENT_ID || ''; +export const AUTH_CLIENT_SECRET = process.env.AUTH_CLIENT_SECRET || ''; +export const RATE_LIMIT_REWARD_GIVE = Number(process.env.RATE_LIMIT_REWARD_GIVE) || ''; +export const RATE_LIMIT_REWARD_CLAIM = Number(process.env.RATE_LIMIT_REWARD_CLAIM) || ''; +export const RATE_LIMIT_REWARD_GIVE_WINDOW = Number(process.env.RATE_LIMIT_REWARD_GIVE_WINDOW) || ''; +export const RATE_LIMIT_REWARD_CLAIM_WINDOW = Number(process.env.RATE_LIMIT_REWARD_CLAIM_WINDOW) || ''; +export const INITIAL_ACCESS_TOKEN = process.env.INITIAL_ACCESS_TOKEN || ''; +export const CIRCULATING_SUPPLY = process.env.CIRCULATING_SUPPLY || ''; +export const INFURA_PROJECT_ID = process.env.INFURA_PROJECT_ID || ''; +export const INFURA_IPFS_PROJECT_ID = process.env.INFURA_IPFS_PROJECT_ID || ''; +export const INFURA_IPFS_PROJECT_SECRET = process.env.INFURA_IPFS_PROJECT_SECRET || ''; +export const MINIMUM_GAS_LIMIT = 54680 || ''; +export const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || ''; +export const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || ''; +export const AWS_S3_PUBLIC_BUCKET_NAME = process.env.AWS_S3_PUBLIC_BUCKET_NAME || ''; +export const AWS_S3_PUBLIC_BUCKET_REGION = process.env.AWS_S3_PUBLIC_BUCKET_REGION || ''; +export const AWS_S3_PRIVATE_BUCKET_NAME = process.env.AWS_S3_PRIVATE_BUCKET_NAME || ''; +export const AWS_S3_PRIVATE_BUCKET_REGION = process.env.AWS_S3_PRIVATE_BUCKET_REGION || ''; +export const POLYGON_RELAYER = process.env.POLYGON_RELAYER || ''; +export const POLYGON_RELAYER_API_KEY = process.env.POLYGON_RELAYER_API_KEY || ''; +export const POLYGON_RELAYER_API_SECRET = process.env.POLYGON_RELAYER_API_SECRET || ''; +export const LOCAL_CERT = process.env.LOCAL_CERT || ''; +export const LOCAL_CERT_KEY = process.env.LOCAL_CERT_KEY || ''; +export const ADDRESS_ZERO = '0x0000000000000000000000000000000000000000'; +export const RELAYER_SPEED = (process.env.RELAYER_SPEED || 'fastest') as Speed; +export const MIXPANEL_TOKEN = process.env.MIXPANEL_TOKEN || ''; +export const MIXPANEL_API_URL = 'https://api.mixpanel.com'; +export const CYPRESS_EMAIL = process.env.CYPRESS_EMAIL || 'cypress@thx.network'; +export const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || ''; +export const STRIPE_SECRET_WEBHOOK = process.env.STRIPE_SECRET_WEBHOOK || ''; +export const ALCHEMY_API_KEY = process.env.ALCHEMY_API_KEY || ''; +export const TWITTER_API_TOKEN = process.env.TWITTER_API_TOKEN || ''; +export const IPFS_BASE_URL = 'https://ipfs.io/ipfs/'; +export const WEBHOOK_REFERRAL = process.env.WEBHOOK_REFERRAL || ''; +export const WEBHOOK_MILESTONE = process.env.WEBHOOK_MILESTONE || ''; +export const SAFE_TXS_SERVICE = process.env.SAFE_TXS_SERVICE || 'https://safe-transaction-polygon.safe.global'; +export const BOT_TOKEN = process.env.BOT_TOKEN || ''; +export const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID || ''; +export const GITCOIN_API_KEY = process.env.GITCOIN_API_KEY || ''; +export const BALANCER_POOL_ID = '0xb204bf10bc3a5435017d3db247f56da601dfe08a0002000000000000000000fe'; +export const PINATA_API_JWT = process.env.PINATA_API_JWT || ''; +export const ALLOWED_API_CLIENT_ID = process.env.ALLOWED_API_CLIENT_ID || ''; +export const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''; +export const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || ''; +export const GCLOUD_PROJECT_ID = process.env.GCLOUD_PROJECT_ID || ''; +export const GCLOUD_RECAPTCHA_API_KEY = process.env.GCLOUD_RECAPTCHA_API_KEY || ''; +export const GCLOUD_RECAPTCHA_SITE_KEY = process.env.GCLOUD_RECAPTCHA_SITE_KEY || ''; +export const THX_CLIENT_ID = process.env.THX_CLIENT_ID || ''; +export const THX_CLIENT_SECRET = process.env.THX_CLIENT_SECRET || ''; +export const WEBHOOK_SIGNING_SECRET = process.env.WEBHOOK_SIGNING_SECRET || ''; diff --git a/apps/api/src/app/connection-profiles/cpp-curator.json b/apps/api/src/app/connection-profiles/cpp-curator.json new file mode 100644 index 000000000..36719936d --- /dev/null +++ b/apps/api/src/app/connection-profiles/cpp-curator.json @@ -0,0 +1,37 @@ +{ + "name": "test-network-CuratorOrg", + "version": "1.0.0", + "client": { + "organization": "CuratorOrg" + }, + "organizations": { + "CuratorOrg": { + "mspid": "CuratorOrg", + "peers": ["peer0.curator.local"], + "certificateAuthorities": ["ca.curator.local"] + } + }, + "peers": { + "peer0.curator.local": { + "url": "grpcs://localhost:7041", + "tlsCACerts": { + "path": "/Users/peterpolman/Sites/galachain/test-network/fablo-target/fabric-config/crypto-config/peerOrganizations/curator.local/peers/peer0.curator.local/tls/ca.crt" + }, + "grpcOptions": { + "ssl-target-name-override": "peer0.curator.local" + } + } + }, + "certificateAuthorities": { + "ca.curator.local": { + "url": "https://localhost:7040", + "caName": "ca.curator.local", + "tlsCACerts": { + "path": "/Users/peterpolman/Sites/galachain/test-network/fablo-target/fabric-config/crypto-config/peerOrganizations/curator.local/peers/ca.curator.local/tls/ca.crt" + }, + "httpOptions": { + "verify": false + } + } + } +} diff --git a/apps/api/src/app/connection-profiles/cpp-partner.json b/apps/api/src/app/connection-profiles/cpp-partner.json new file mode 100644 index 000000000..4afcd6ebc --- /dev/null +++ b/apps/api/src/app/connection-profiles/cpp-partner.json @@ -0,0 +1,41 @@ +{ + "name": "test-network-PartnerOrg1", + "version": "1.0.0", + "client": { + "organization": "PartnerOrg1" + }, + "organizations": { + "PartnerOrg1": { + "mspid": "PartnerOrg1", + "peers": [ + "peer0.partner1.local" + ], + "certificateAuthorities": [ + "ca.partner1.local" + ] + } + }, + "peers": { + "peer0.partner1.local": { + "url": "grpcs://localhost:7061", + "tlsCACerts": { + "path": "/Users/peterpolman/Sites/galachain/test-network/fablo-target/fabric-config/crypto-config/peerOrganizations/partner1.local/peers/peer0.partner1.local/tls/ca.crt" + }, + "grpcOptions": { + "ssl-target-name-override": "peer0.partner1.local" + } + } + }, + "certificateAuthorities": { + "ca.partner1.local": { + "url": "https://localhost:7060", + "caName": "ca.partner1.local", + "tlsCACerts": { + "path": "/Users/peterpolman/Sites/galachain/test-network/fablo-target/fabric-config/crypto-config/peerOrganizations/partner1.local/peers/ca.partner1.local/tls/ca.crt" + }, + "httpOptions": { + "verify": false + } + } + } +} \ No newline at end of file diff --git a/apps/api/src/app/connection-profiles/cpp-users.json b/apps/api/src/app/connection-profiles/cpp-users.json new file mode 100644 index 000000000..f95a21cca --- /dev/null +++ b/apps/api/src/app/connection-profiles/cpp-users.json @@ -0,0 +1,59 @@ +{ + "name": "test-network-UsersOrg1", + "version": "1.0.0", + "client": { + "organization": "UsersOrg1" + }, + "organizations": { + "CuratorOrg": { + "mspid": "CuratorOrg", + "peers": [ + "peer0.curator.local" + ] + }, + "PartnerOrg1": { + "mspid": "PartnerOrg1", + "peers": [ + "peer0.partner1.local" + ] + }, + "UsersOrg1": { + "mspid": "UsersOrg1", + "certificateAuthorities": [ + "ca.users1.local" + ] + } + }, + "peers": { + "peer0.curator.local": { + "url": "grpcs://localhost:7041", + "tlsCACerts": { + "path": "/Users/peterpolman/Sites/galachain/test-network/fablo-target/fabric-config/crypto-config/peerOrganizations/curator.local/peers/peer0.curator.local/tls/ca.crt" + }, + "grpcOptions": { + "ssl-target-name-override": "peer0.curator.local" + } + }, + "peer0.partner1.local": { + "url": "grpcs://localhost:7061", + "tlsCACerts": { + "path": "/Users/peterpolman/Sites/galachain/test-network/fablo-target/fabric-config/crypto-config/peerOrganizations/partner1.local/peers/peer0.partner1.local/tls/ca.crt" + }, + "grpcOptions": { + "ssl-target-name-override": "peer0.partner1.local" + } + } + }, + "certificateAuthorities": { + "ca.users1.local": { + "url": "https://localhost:7080", + "caName": "ca.users1.local", + "tlsCACerts": { + "path": "/Users/peterpolman/Sites/galachain/test-network/fablo-target/fabric-config/crypto-config/peerOrganizations/users1.local/peers/ca.users1.local/tls/ca.crt" + }, + "httpOptions": { + "verify": false + } + } + } +} \ No newline at end of file diff --git a/apps/api/src/app/contracts/abis/BAL.json b/apps/api/src/app/contracts/abis/BAL.json new file mode 100644 index 000000000..d1e75ca5a --- /dev/null +++ b/apps/api/src/app/contracts/abis/BAL.json @@ -0,0 +1,315 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "BAL", + "sourceName": "contracts/mock/BAL.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256" + } + ], + "name": "increaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x60806040523480156200001157600080fd5b5060405162000e2538038062000e25833981810160405260408110156200003757600080fd5b50805160209182015160408051808201825260088152672130b630b731b2b960c11b818601908152825180840190935260038084526210905360ea1b968401969096528151949593949193620000909290919062000240565b508051620000a690600490602084019062000240565b50506005805460ff1916601217905550620000c28282620000ca565b5050620002ec565b6001600160a01b03821662000126576040805162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015290519081900360640190fd5b6200013460008383620001d9565b6200015081600254620001de60201b620005ba1790919060201c565b6002556001600160a01b0382166000908152602081815260409091205462000183918390620005ba620001de821b17901c565b6001600160a01b0383166000818152602081815260408083209490945583518581529351929391927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9281900390910190a35050565b505050565b60008282018381101562000239576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b828054600181600116156101000203166002900490600052602060002090601f016020900481019282620002785760008555620002c3565b82601f106200029357805160ff1916838001178555620002c3565b82800160010185558215620002c3579182015b82811115620002c3578251825591602001919060010190620002a6565b50620002d1929150620002d5565b5090565b5b80821115620002d15760008155600101620002d6565b610b2980620002fc6000396000f3fe608060405234801561001057600080fd5b50600436106100b45760003560e01c806340c10f191161007157806340c10f191461021057806370a082311461023e57806395d89b4114610264578063a457c2d71461026c578063a9059cbb14610298578063dd62ed3e146102c4576100b4565b806306fdde03146100b9578063095ea7b31461013657806318160ddd1461017657806323b872dd14610190578063313ce567146101c657806339509351146101e4575b600080fd5b6100c16102f2565b6040805160208082528351818301528351919283929083019185019080838360005b838110156100fb5781810151838201526020016100e3565b50505050905090810190601f1680156101285780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6101626004803603604081101561014c57600080fd5b506001600160a01b038135169060200135610388565b604080519115158252519081900360200190f35b61017e6103a5565b60408051918252519081900360200190f35b610162600480360360608110156101a657600080fd5b506001600160a01b038135811691602081013590911690604001356103ab565b6101ce610432565b6040805160ff9092168252519081900360200190f35b610162600480360360408110156101fa57600080fd5b506001600160a01b03813516906020013561043b565b61023c6004803603604081101561022657600080fd5b506001600160a01b038135169060200135610489565b005b61017e6004803603602081101561025457600080fd5b50356001600160a01b0316610497565b6100c16104b2565b6101626004803603604081101561028257600080fd5b506001600160a01b038135169060200135610513565b610162600480360360408110156102ae57600080fd5b506001600160a01b03813516906020013561057b565b61017e600480360360408110156102da57600080fd5b506001600160a01b038135811691602001351661058f565b60038054604080516020601f600260001961010060018816150201909516949094049384018190048102820181019092528281526060939092909183018282801561037e5780601f106103535761010080835404028352916020019161037e565b820191906000526020600020905b81548152906001019060200180831161036157829003601f168201915b5050505050905090565b600061039c61039561061b565b848461061f565b50600192915050565b60025490565b60006103b884848461070b565b610428846103c461061b565b61042385604051806060016040528060288152602001610a5e602891396001600160a01b038a1660009081526001602052604081209061040261061b565b6001600160a01b031681526020810191909152604001600020549190610866565b61061f565b5060019392505050565b60055460ff1690565b600061039c61044861061b565b84610423856001600061045961061b565b6001600160a01b03908116825260208083019390935260409182016000908120918c1681529252902054906105ba565b61049382826108fd565b5050565b6001600160a01b031660009081526020819052604090205490565b60048054604080516020601f600260001961010060018816150201909516949094049384018190048102820181019092528281526060939092909183018282801561037e5780601f106103535761010080835404028352916020019161037e565b600061039c61052061061b565b8461042385604051806060016040528060258152602001610acf602591396001600061054a61061b565b6001600160a01b03908116825260208083019390935260409182016000908120918d16815292529020549190610866565b600061039c61058861061b565b848461070b565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b600082820183811015610614576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b3390565b6001600160a01b0383166106645760405162461bcd60e51b8152600401808060200182810382526024815260200180610aab6024913960400191505060405180910390fd5b6001600160a01b0382166106a95760405162461bcd60e51b8152600401808060200182810382526022815260200180610a166022913960400191505060405180910390fd5b6001600160a01b03808416600081815260016020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b6001600160a01b0383166107505760405162461bcd60e51b8152600401808060200182810382526025815260200180610a866025913960400191505060405180910390fd5b6001600160a01b0382166107955760405162461bcd60e51b81526004018080602001828103825260238152602001806109f36023913960400191505060405180910390fd5b6107a08383836109ed565b6107dd81604051806060016040528060268152602001610a38602691396001600160a01b0386166000908152602081905260409020549190610866565b6001600160a01b03808516600090815260208190526040808220939093559084168152205461080c90826105ba565b6001600160a01b038084166000818152602081815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600081848411156108f55760405162461bcd60e51b81526004018080602001828103825283818151815260200191508051906020019080838360005b838110156108ba5781810151838201526020016108a2565b50505050905090810190601f1680156108e75780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b505050900390565b6001600160a01b038216610958576040805162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015290519081900360640190fd5b610964600083836109ed565b60025461097190826105ba565b6002556001600160a01b03821660009081526020819052604090205461099790826105ba565b6001600160a01b0383166000818152602081815260408083209490945583518581529351929391927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9281900390910190a35050565b50505056fe45524332303a207472616e7366657220746f20746865207a65726f206164647265737345524332303a20617070726f766520746f20746865207a65726f206164647265737345524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e636545524332303a207472616e7366657220616d6f756e74206578636565647320616c6c6f77616e636545524332303a207472616e736665722066726f6d20746865207a65726f206164647265737345524332303a20617070726f76652066726f6d20746865207a65726f206164647265737345524332303a2064656372656173656420616c6c6f77616e63652062656c6f77207a65726fa264697066735822122054d3aaaafb4397e9ba9a956156cfdf7c50a74c32a8e663bdf85e3a80c501d06464736f6c63430007060033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100b45760003560e01c806340c10f191161007157806340c10f191461021057806370a082311461023e57806395d89b4114610264578063a457c2d71461026c578063a9059cbb14610298578063dd62ed3e146102c4576100b4565b806306fdde03146100b9578063095ea7b31461013657806318160ddd1461017657806323b872dd14610190578063313ce567146101c657806339509351146101e4575b600080fd5b6100c16102f2565b6040805160208082528351818301528351919283929083019185019080838360005b838110156100fb5781810151838201526020016100e3565b50505050905090810190601f1680156101285780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6101626004803603604081101561014c57600080fd5b506001600160a01b038135169060200135610388565b604080519115158252519081900360200190f35b61017e6103a5565b60408051918252519081900360200190f35b610162600480360360608110156101a657600080fd5b506001600160a01b038135811691602081013590911690604001356103ab565b6101ce610432565b6040805160ff9092168252519081900360200190f35b610162600480360360408110156101fa57600080fd5b506001600160a01b03813516906020013561043b565b61023c6004803603604081101561022657600080fd5b506001600160a01b038135169060200135610489565b005b61017e6004803603602081101561025457600080fd5b50356001600160a01b0316610497565b6100c16104b2565b6101626004803603604081101561028257600080fd5b506001600160a01b038135169060200135610513565b610162600480360360408110156102ae57600080fd5b506001600160a01b03813516906020013561057b565b61017e600480360360408110156102da57600080fd5b506001600160a01b038135811691602001351661058f565b60038054604080516020601f600260001961010060018816150201909516949094049384018190048102820181019092528281526060939092909183018282801561037e5780601f106103535761010080835404028352916020019161037e565b820191906000526020600020905b81548152906001019060200180831161036157829003601f168201915b5050505050905090565b600061039c61039561061b565b848461061f565b50600192915050565b60025490565b60006103b884848461070b565b610428846103c461061b565b61042385604051806060016040528060288152602001610a5e602891396001600160a01b038a1660009081526001602052604081209061040261061b565b6001600160a01b031681526020810191909152604001600020549190610866565b61061f565b5060019392505050565b60055460ff1690565b600061039c61044861061b565b84610423856001600061045961061b565b6001600160a01b03908116825260208083019390935260409182016000908120918c1681529252902054906105ba565b61049382826108fd565b5050565b6001600160a01b031660009081526020819052604090205490565b60048054604080516020601f600260001961010060018816150201909516949094049384018190048102820181019092528281526060939092909183018282801561037e5780601f106103535761010080835404028352916020019161037e565b600061039c61052061061b565b8461042385604051806060016040528060258152602001610acf602591396001600061054a61061b565b6001600160a01b03908116825260208083019390935260409182016000908120918d16815292529020549190610866565b600061039c61058861061b565b848461070b565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b600082820183811015610614576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b3390565b6001600160a01b0383166106645760405162461bcd60e51b8152600401808060200182810382526024815260200180610aab6024913960400191505060405180910390fd5b6001600160a01b0382166106a95760405162461bcd60e51b8152600401808060200182810382526022815260200180610a166022913960400191505060405180910390fd5b6001600160a01b03808416600081815260016020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b6001600160a01b0383166107505760405162461bcd60e51b8152600401808060200182810382526025815260200180610a866025913960400191505060405180910390fd5b6001600160a01b0382166107955760405162461bcd60e51b81526004018080602001828103825260238152602001806109f36023913960400191505060405180910390fd5b6107a08383836109ed565b6107dd81604051806060016040528060268152602001610a38602691396001600160a01b0386166000908152602081905260409020549190610866565b6001600160a01b03808516600090815260208190526040808220939093559084168152205461080c90826105ba565b6001600160a01b038084166000818152602081815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600081848411156108f55760405162461bcd60e51b81526004018080602001828103825283818151815260200191508051906020019080838360005b838110156108ba5781810151838201526020016108a2565b50505050905090810190601f1680156108e75780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b505050900390565b6001600160a01b038216610958576040805162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015290519081900360640190fd5b610964600083836109ed565b60025461097190826105ba565b6002556001600160a01b03821660009081526020819052604090205461099790826105ba565b6001600160a01b0383166000818152602081815260408083209490945583518581529351929391927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9281900390910190a35050565b50505056fe45524332303a207472616e7366657220746f20746865207a65726f206164647265737345524332303a20617070726f766520746f20746865207a65726f206164647265737345524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e636545524332303a207472616e7366657220616d6f756e74206578636565647320616c6c6f77616e636545524332303a207472616e736665722066726f6d20746865207a65726f206164647265737345524332303a20617070726f76652066726f6d20746865207a65726f206164647265737345524332303a2064656372656173656420616c6c6f77616e63652062656c6f77207a65726fa264697066735822122054d3aaaafb4397e9ba9a956156cfdf7c50a74c32a8e663bdf85e3a80c501d06464736f6c63430007060033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/abis/BPT.json b/apps/api/src/app/contracts/abis/BPT.json new file mode 100644 index 000000000..e39ae6960 --- /dev/null +++ b/apps/api/src/app/contracts/abis/BPT.json @@ -0,0 +1,349 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "BPT", + "sourceName": "contracts/mock/BPT.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "_to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "getPoolId", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "getVault", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256" + } + ], + "name": "increaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_vault", + "type": "address" + } + ], + "name": "setVault", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "vault", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + } + ], + "bytecode": "0x60806040523480156200001157600080fd5b5060405162000e0738038062000e07833981810160405260408110156200003757600080fd5b508051602091820151604080518082018252600c8082526b06460aaa688865a7060a890b60a31b82870181815284518086019095529184529583019590955280519394929390926200008d91600391906200023d565b508051620000a39060049060208401906200023d565b50506005805460ff1916601217905550620000bf8282620000c7565b5050620002e9565b6001600160a01b03821662000123576040805162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015290519081900360640190fd5b6200013160008383620001d6565b6200014d81600254620001db60201b6200068f1790919060201c565b6002556001600160a01b03821660009081526020818152604090912054620001809183906200068f620001db821b17901c565b6001600160a01b0383166000818152602081815260408083209490945583518581529351929391927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9281900390910190a35050565b505050565b60008282018381101562000236576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b828054600181600116156101000203166002900490600052602060002090601f016020900481019282620002755760008555620002c0565b82601f106200029057805160ff1916838001178555620002c0565b82800160010185558215620002c0579182015b82811115620002c0578251825591602001919060010190620002a3565b50620002ce929150620002d2565b5090565b5b80821115620002ce5760008155600101620002d3565b610b0e80620002f96000396000f3fe608060405234801561001057600080fd5b50600436106100f55760003560e01c80636817031b11610097578063a457c2d711610066578063a457c2d7146102d3578063a9059cbb146102ff578063dd62ed3e1461032b578063fbfa77cf14610359576100f5565b80636817031b1461025957806370a08231146102815780638d928af8146102a757806395d89b41146102cb576100f5565b806323b872dd116100d357806323b872dd146101d1578063313ce5671461020757806338fff2d014610225578063395093511461022d576100f5565b806306fdde03146100fa578063095ea7b31461017757806318160ddd146101b7575b600080fd5b610102610361565b6040805160208082528351818301528351919283929083019185019080838360005b8381101561013c578181015183820152602001610124565b50505050905090810190601f1680156101695780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6101a36004803603604081101561018d57600080fd5b506001600160a01b0381351690602001356103f7565b604080519115158252519081900360200190f35b6101bf610414565b60408051918252519081900360200190f35b6101a3600480360360608110156101e757600080fd5b506001600160a01b0381358116916020810135909116906040013561041a565b61020f6104a1565b6040805160ff9092168252519081900360200190f35b6101bf6104aa565b6101a36004803603604081101561024357600080fd5b506001600160a01b0381351690602001356104ce565b61027f6004803603602081101561026f57600080fd5b50356001600160a01b031661051c565b005b6101bf6004803603602081101561029757600080fd5b50356001600160a01b0316610544565b6102af61055f565b604080516001600160a01b039092168252519081900360200190f35b610102610573565b6101a3600480360360408110156102e957600080fd5b506001600160a01b0381351690602001356105d4565b6101a36004803603604081101561031557600080fd5b506001600160a01b03813516906020013561063c565b6101bf6004803603604081101561034157600080fd5b506001600160a01b0381358116916020013516610650565b6102af61067b565b60038054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156103ed5780601f106103c2576101008083540402835291602001916103ed565b820191906000526020600020905b8154815290600101906020018083116103d057829003601f168201915b5050505050905090565b600061040b6104046106f0565b84846106f4565b50600192915050565b60025490565b60006104278484846107e0565b610497846104336106f0565b61049285604051806060016040528060288152602001610a43602891396001600160a01b038a166000908152600160205260408120906104716106f0565b6001600160a01b03168152602081019190915260400160002054919061093b565b6106f4565b5060019392505050565b60055460ff1690565b7fb204bf10bc3a5435017d3db247f56da601dfe08a0002000000000000000000fe90565b600061040b6104db6106f0565b8461049285600160006104ec6106f0565b6001600160a01b03908116825260208083019390935260409182016000908120918c16815292529020549061068f565b600580546001600160a01b0390921661010002610100600160a81b0319909216919091179055565b6001600160a01b031660009081526020819052604090205490565b60055461010090046001600160a01b031690565b60048054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156103ed5780601f106103c2576101008083540402835291602001916103ed565b600061040b6105e16106f0565b8461049285604051806060016040528060258152602001610ab4602591396001600061060b6106f0565b6001600160a01b03908116825260208083019390935260409182016000908120918d1681529252902054919061093b565b600061040b6106496106f0565b84846107e0565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b60055461010090046001600160a01b031681565b6000828201838110156106e9576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b3390565b6001600160a01b0383166107395760405162461bcd60e51b8152600401808060200182810382526024815260200180610a906024913960400191505060405180910390fd5b6001600160a01b03821661077e5760405162461bcd60e51b81526004018080602001828103825260228152602001806109fb6022913960400191505060405180910390fd5b6001600160a01b03808416600081815260016020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b6001600160a01b0383166108255760405162461bcd60e51b8152600401808060200182810382526025815260200180610a6b6025913960400191505060405180910390fd5b6001600160a01b03821661086a5760405162461bcd60e51b81526004018080602001828103825260238152602001806109d86023913960400191505060405180910390fd5b6108758383836109d2565b6108b281604051806060016040528060268152602001610a1d602691396001600160a01b038616600090815260208190526040902054919061093b565b6001600160a01b0380851660009081526020819052604080822093909355908416815220546108e1908261068f565b6001600160a01b038084166000818152602081815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600081848411156109ca5760405162461bcd60e51b81526004018080602001828103825283818151815260200191508051906020019080838360005b8381101561098f578181015183820152602001610977565b50505050905090810190601f1680156109bc5780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b505050900390565b50505056fe45524332303a207472616e7366657220746f20746865207a65726f206164647265737345524332303a20617070726f766520746f20746865207a65726f206164647265737345524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e636545524332303a207472616e7366657220616d6f756e74206578636565647320616c6c6f77616e636545524332303a207472616e736665722066726f6d20746865207a65726f206164647265737345524332303a20617070726f76652066726f6d20746865207a65726f206164647265737345524332303a2064656372656173656420616c6c6f77616e63652062656c6f77207a65726fa264697066735822122092787e3cbd971df817541c818008a4dc0435dc43da4a6c2f5e3e16fc0203cfd564736f6c63430007060033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100f55760003560e01c80636817031b11610097578063a457c2d711610066578063a457c2d7146102d3578063a9059cbb146102ff578063dd62ed3e1461032b578063fbfa77cf14610359576100f5565b80636817031b1461025957806370a08231146102815780638d928af8146102a757806395d89b41146102cb576100f5565b806323b872dd116100d357806323b872dd146101d1578063313ce5671461020757806338fff2d014610225578063395093511461022d576100f5565b806306fdde03146100fa578063095ea7b31461017757806318160ddd146101b7575b600080fd5b610102610361565b6040805160208082528351818301528351919283929083019185019080838360005b8381101561013c578181015183820152602001610124565b50505050905090810190601f1680156101695780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6101a36004803603604081101561018d57600080fd5b506001600160a01b0381351690602001356103f7565b604080519115158252519081900360200190f35b6101bf610414565b60408051918252519081900360200190f35b6101a3600480360360608110156101e757600080fd5b506001600160a01b0381358116916020810135909116906040013561041a565b61020f6104a1565b6040805160ff9092168252519081900360200190f35b6101bf6104aa565b6101a36004803603604081101561024357600080fd5b506001600160a01b0381351690602001356104ce565b61027f6004803603602081101561026f57600080fd5b50356001600160a01b031661051c565b005b6101bf6004803603602081101561029757600080fd5b50356001600160a01b0316610544565b6102af61055f565b604080516001600160a01b039092168252519081900360200190f35b610102610573565b6101a3600480360360408110156102e957600080fd5b506001600160a01b0381351690602001356105d4565b6101a36004803603604081101561031557600080fd5b506001600160a01b03813516906020013561063c565b6101bf6004803603604081101561034157600080fd5b506001600160a01b0381358116916020013516610650565b6102af61067b565b60038054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156103ed5780601f106103c2576101008083540402835291602001916103ed565b820191906000526020600020905b8154815290600101906020018083116103d057829003601f168201915b5050505050905090565b600061040b6104046106f0565b84846106f4565b50600192915050565b60025490565b60006104278484846107e0565b610497846104336106f0565b61049285604051806060016040528060288152602001610a43602891396001600160a01b038a166000908152600160205260408120906104716106f0565b6001600160a01b03168152602081019190915260400160002054919061093b565b6106f4565b5060019392505050565b60055460ff1690565b7fb204bf10bc3a5435017d3db247f56da601dfe08a0002000000000000000000fe90565b600061040b6104db6106f0565b8461049285600160006104ec6106f0565b6001600160a01b03908116825260208083019390935260409182016000908120918c16815292529020549061068f565b600580546001600160a01b0390921661010002610100600160a81b0319909216919091179055565b6001600160a01b031660009081526020819052604090205490565b60055461010090046001600160a01b031690565b60048054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156103ed5780601f106103c2576101008083540402835291602001916103ed565b600061040b6105e16106f0565b8461049285604051806060016040528060258152602001610ab4602591396001600061060b6106f0565b6001600160a01b03908116825260208083019390935260409182016000908120918d1681529252902054919061093b565b600061040b6106496106f0565b84846107e0565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b60055461010090046001600160a01b031681565b6000828201838110156106e9576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b3390565b6001600160a01b0383166107395760405162461bcd60e51b8152600401808060200182810382526024815260200180610a906024913960400191505060405180910390fd5b6001600160a01b03821661077e5760405162461bcd60e51b81526004018080602001828103825260228152602001806109fb6022913960400191505060405180910390fd5b6001600160a01b03808416600081815260016020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b6001600160a01b0383166108255760405162461bcd60e51b8152600401808060200182810382526025815260200180610a6b6025913960400191505060405180910390fd5b6001600160a01b03821661086a5760405162461bcd60e51b81526004018080602001828103825260238152602001806109d86023913960400191505060405180910390fd5b6108758383836109d2565b6108b281604051806060016040528060268152602001610a1d602691396001600160a01b038616600090815260208190526040902054919061093b565b6001600160a01b0380851660009081526020819052604080822093909355908416815220546108e1908261068f565b6001600160a01b038084166000818152602081815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600081848411156109ca5760405162461bcd60e51b81526004018080602001828103825283818151815260200191508051906020019080838360005b8381101561098f578181015183820152602001610977565b50505050905090810190601f1680156109bc5780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b505050900390565b50505056fe45524332303a207472616e7366657220746f20746865207a65726f206164647265737345524332303a20617070726f766520746f20746865207a65726f206164647265737345524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e636545524332303a207472616e7366657220616d6f756e74206578636565647320616c6c6f77616e636545524332303a207472616e736665722066726f6d20746865207a65726f206164647265737345524332303a20617070726f76652066726f6d20746865207a65726f206164647265737345524332303a2064656372656173656420616c6c6f77616e63652062656c6f77207a65726fa264697066735822122092787e3cbd971df817541c818008a4dc0435dc43da4a6c2f5e3e16fc0203cfd564736f6c63430007060033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/abis/BPTGauge.json b/apps/api/src/app/contracts/abis/BPTGauge.json new file mode 100644 index 000000000..ddf9bd5ed --- /dev/null +++ b/apps/api/src/app/contracts/abis/BPTGauge.json @@ -0,0 +1,395 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "BPTGauge", + "sourceName": "contracts/mock/BPTGauge.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "_bpt", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Deposit", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Withdraw", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "bpt", + "outputs": [ + { + "internalType": "contract IERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_value", + "type": "uint256" + } + ], + "name": "deposit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256" + } + ], + "name": "increaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "lp_token", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_value", + "type": "uint256" + } + ], + "name": "withdraw", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "working_supply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function" + } + ], + "bytecode": "0x60806040523480156200001157600080fd5b5060405162000ff438038062000ff483398101604081905262000034916200018d565b60405180606001604052806023815260200162000fd160239139604051806040016040528060128152602001713230555344432d38305448582d676175676560701b815250816003908051906020019062000091929190620000e1565b508051620000a7906004906020840190620000e1565b5050600580546001600160a01b0390931661010002610100600160a81b031960ff19909416601217939093169290921790915550620001bd565b828054600181600116156101000203166002900490600052602060002090601f01602090048101928262000119576000855562000164565b82601f106200013457805160ff191683800117855562000164565b8280016001018555821562000164579182015b828111156200016457825182559160200191906001019062000147565b506200017292915062000176565b5090565b5b8082111562000172576000815560010162000177565b6000602082840312156200019f578081fd5b81516001600160a01b0381168114620001b6578182fd5b9392505050565b610e0480620001cd6000396000f3fe608060405234801561001057600080fd5b50600436106101005760003560e01c8063546af3c311610097578063a457c2d711610066578063a457c2d7146101e8578063a9059cbb146101fb578063b6b55f251461020e578063dd62ed3e1461022157610100565b8063546af3c3146101b057806370a08231146101c557806382c63066146101d857806395d89b41146101e057610100565b806323b872dd116100d357806323b872dd146101605780632e1a7d4d14610173578063313ce56714610188578063395093511461019d57610100565b806306fdde0314610105578063095ea7b31461012357806317e280891461014357806318160ddd14610158575b600080fd5b61010d610234565b60405161011a9190610c63565b60405180910390f35b610136610131366004610ba6565b6102ca565b60405161011a9190610c58565b61014b6102e7565b60405161011a9190610cb6565b61014b6102f5565b61013661016e366004610b6b565b6102fb565b610186610181366004610bef565b610382565b005b61019061045c565b60405161011a9190610cbf565b6101366101ab366004610ba6565b610465565b6101b86104b3565b60405161011a9190610c07565b61014b6101d3366004610b1f565b6104c7565b6101b86104e6565b61010d6104fa565b6101366101f6366004610ba6565b61055b565b610136610209366004610ba6565b6105c3565b61018661021c366004610bef565b6105d7565b61014b61022f366004610b39565b6106a5565b60038054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156102c05780601f10610295576101008083540402835291602001916102c0565b820191906000526020600020905b8154815290600101906020018083116102a357829003601f168201915b5050505050905090565b60006102de6102d76106d0565b84846106d4565b50600192915050565b697c1238ba7d2d4f8ef86190565b60025490565b60006103088484846107c0565b610378846103146106d0565b61037385604051806060016040528060288152602001610d39602891396001600160a01b038a166000908152600160205260408120906103526106d0565b6001600160a01b03168152602081019190915260400160002054919061091b565b6106d4565b5060019392505050565b60055460405163a9059cbb60e01b81526101009091046001600160a01b03169063a9059cbb906103b89033908590600401610c3f565b602060405180830381600087803b1580156103d257600080fd5b505af11580156103e6573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061040a9190610bcf565b50610417336000836102fb565b50336001600160a01b03167f884edad9ce6fa2440d8a54cc123490eb96d2768479d49ff9c7366125a9424364826040516104519190610cb6565b60405180910390a250565b60055460ff1690565b60006102de6104726106d0565b8461037385600160006104836106d0565b6001600160a01b03908116825260208083019390935260409182016000908120918c1681529252902054906109b2565b60055461010090046001600160a01b031681565b6001600160a01b0381166000908152602081905260409020545b919050565b60055461010090046001600160a01b031690565b60048054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156102c05780601f10610295576101008083540402835291602001916102c0565b60006102de6105686106d0565b8461037385604051806060016040528060258152602001610daa60259139600160006105926106d0565b6001600160a01b03908116825260208083019390935260409182016000908120918d1681529252902054919061091b565b60006102de6105d06106d0565b84846107c0565b6005546040516323b872dd60e01b81526101009091046001600160a01b0316906323b872dd9061060f90339030908690600401610c1b565b602060405180830381600087803b15801561062957600080fd5b505af115801561063d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906106619190610bcf565b5061066c3382610a13565b336001600160a01b03167fe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c826040516104519190610cb6565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b3390565b6001600160a01b0383166107195760405162461bcd60e51b8152600401808060200182810382526024815260200180610d866024913960400191505060405180910390fd5b6001600160a01b03821661075e5760405162461bcd60e51b8152600401808060200182810382526022815260200180610cf16022913960400191505060405180910390fd5b6001600160a01b03808416600081815260016020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b6001600160a01b0383166108055760405162461bcd60e51b8152600401808060200182810382526025815260200180610d616025913960400191505060405180910390fd5b6001600160a01b03821661084a5760405162461bcd60e51b8152600401808060200182810382526023815260200180610cce6023913960400191505060405180910390fd5b610855838383610b03565b61089281604051806060016040528060268152602001610d13602691396001600160a01b038616600090815260208190526040902054919061091b565b6001600160a01b0380851660009081526020819052604080822093909355908416815220546108c190826109b2565b6001600160a01b038084166000818152602081815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600081848411156109aa5760405162461bcd60e51b81526004018080602001828103825283818151815260200191508051906020019080838360005b8381101561096f578181015183820152602001610957565b50505050905090810190601f16801561099c5780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b505050900390565b600082820183811015610a0c576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b6001600160a01b038216610a6e576040805162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015290519081900360640190fd5b610a7a60008383610b03565b600254610a8790826109b2565b6002556001600160a01b038216600090815260208190526040902054610aad90826109b2565b6001600160a01b0383166000818152602081815260408083209490945583518581529351929391927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9281900390910190a35050565b505050565b80356001600160a01b03811681146104e157600080fd5b600060208284031215610b30578081fd5b610a0c82610b08565b60008060408385031215610b4b578081fd5b610b5483610b08565b9150610b6260208401610b08565b90509250929050565b600080600060608486031215610b7f578081fd5b610b8884610b08565b9250610b9660208501610b08565b9150604084013590509250925092565b60008060408385031215610bb8578182fd5b610bc183610b08565b946020939093013593505050565b600060208284031215610be0578081fd5b81518015158114610a0c578182fd5b600060208284031215610c00578081fd5b5035919050565b6001600160a01b0391909116815260200190565b6001600160a01b039384168152919092166020820152604081019190915260600190565b6001600160a01b03929092168252602082015260400190565b901515815260200190565b6000602080835283518082850152825b81811015610c8f57858101830151858201604001528201610c73565b81811115610ca05783604083870101525b50601f01601f1916929092016040019392505050565b90815260200190565b60ff9190911681526020019056fe45524332303a207472616e7366657220746f20746865207a65726f206164647265737345524332303a20617070726f766520746f20746865207a65726f206164647265737345524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e636545524332303a207472616e7366657220616d6f756e74206578636565647320616c6c6f77616e636545524332303a207472616e736665722066726f6d20746865207a65726f206164647265737345524332303a20617070726f76652066726f6d20746865207a65726f206164647265737345524332303a2064656372656173656420616c6c6f77616e63652062656c6f77207a65726fa264697066735822122069a94b4190fe4204521f185375175472fa777f0f59edf162489bbd1a5bdb764264736f6c6343000706003342616c616e636572203230555344432d3830544858204761756765204465706f736974", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106101005760003560e01c8063546af3c311610097578063a457c2d711610066578063a457c2d7146101e8578063a9059cbb146101fb578063b6b55f251461020e578063dd62ed3e1461022157610100565b8063546af3c3146101b057806370a08231146101c557806382c63066146101d857806395d89b41146101e057610100565b806323b872dd116100d357806323b872dd146101605780632e1a7d4d14610173578063313ce56714610188578063395093511461019d57610100565b806306fdde0314610105578063095ea7b31461012357806317e280891461014357806318160ddd14610158575b600080fd5b61010d610234565b60405161011a9190610c63565b60405180910390f35b610136610131366004610ba6565b6102ca565b60405161011a9190610c58565b61014b6102e7565b60405161011a9190610cb6565b61014b6102f5565b61013661016e366004610b6b565b6102fb565b610186610181366004610bef565b610382565b005b61019061045c565b60405161011a9190610cbf565b6101366101ab366004610ba6565b610465565b6101b86104b3565b60405161011a9190610c07565b61014b6101d3366004610b1f565b6104c7565b6101b86104e6565b61010d6104fa565b6101366101f6366004610ba6565b61055b565b610136610209366004610ba6565b6105c3565b61018661021c366004610bef565b6105d7565b61014b61022f366004610b39565b6106a5565b60038054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156102c05780601f10610295576101008083540402835291602001916102c0565b820191906000526020600020905b8154815290600101906020018083116102a357829003601f168201915b5050505050905090565b60006102de6102d76106d0565b84846106d4565b50600192915050565b697c1238ba7d2d4f8ef86190565b60025490565b60006103088484846107c0565b610378846103146106d0565b61037385604051806060016040528060288152602001610d39602891396001600160a01b038a166000908152600160205260408120906103526106d0565b6001600160a01b03168152602081019190915260400160002054919061091b565b6106d4565b5060019392505050565b60055460405163a9059cbb60e01b81526101009091046001600160a01b03169063a9059cbb906103b89033908590600401610c3f565b602060405180830381600087803b1580156103d257600080fd5b505af11580156103e6573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061040a9190610bcf565b50610417336000836102fb565b50336001600160a01b03167f884edad9ce6fa2440d8a54cc123490eb96d2768479d49ff9c7366125a9424364826040516104519190610cb6565b60405180910390a250565b60055460ff1690565b60006102de6104726106d0565b8461037385600160006104836106d0565b6001600160a01b03908116825260208083019390935260409182016000908120918c1681529252902054906109b2565b60055461010090046001600160a01b031681565b6001600160a01b0381166000908152602081905260409020545b919050565b60055461010090046001600160a01b031690565b60048054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156102c05780601f10610295576101008083540402835291602001916102c0565b60006102de6105686106d0565b8461037385604051806060016040528060258152602001610daa60259139600160006105926106d0565b6001600160a01b03908116825260208083019390935260409182016000908120918d1681529252902054919061091b565b60006102de6105d06106d0565b84846107c0565b6005546040516323b872dd60e01b81526101009091046001600160a01b0316906323b872dd9061060f90339030908690600401610c1b565b602060405180830381600087803b15801561062957600080fd5b505af115801561063d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906106619190610bcf565b5061066c3382610a13565b336001600160a01b03167fe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c826040516104519190610cb6565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b3390565b6001600160a01b0383166107195760405162461bcd60e51b8152600401808060200182810382526024815260200180610d866024913960400191505060405180910390fd5b6001600160a01b03821661075e5760405162461bcd60e51b8152600401808060200182810382526022815260200180610cf16022913960400191505060405180910390fd5b6001600160a01b03808416600081815260016020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b6001600160a01b0383166108055760405162461bcd60e51b8152600401808060200182810382526025815260200180610d616025913960400191505060405180910390fd5b6001600160a01b03821661084a5760405162461bcd60e51b8152600401808060200182810382526023815260200180610cce6023913960400191505060405180910390fd5b610855838383610b03565b61089281604051806060016040528060268152602001610d13602691396001600160a01b038616600090815260208190526040902054919061091b565b6001600160a01b0380851660009081526020819052604080822093909355908416815220546108c190826109b2565b6001600160a01b038084166000818152602081815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600081848411156109aa5760405162461bcd60e51b81526004018080602001828103825283818151815260200191508051906020019080838360005b8381101561096f578181015183820152602001610957565b50505050905090810190601f16801561099c5780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b505050900390565b600082820183811015610a0c576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b6001600160a01b038216610a6e576040805162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015290519081900360640190fd5b610a7a60008383610b03565b600254610a8790826109b2565b6002556001600160a01b038216600090815260208190526040902054610aad90826109b2565b6001600160a01b0383166000818152602081815260408083209490945583518581529351929391927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9281900390910190a35050565b505050565b80356001600160a01b03811681146104e157600080fd5b600060208284031215610b30578081fd5b610a0c82610b08565b60008060408385031215610b4b578081fd5b610b5483610b08565b9150610b6260208401610b08565b90509250929050565b600080600060608486031215610b7f578081fd5b610b8884610b08565b9250610b9660208501610b08565b9150604084013590509250925092565b60008060408385031215610bb8578182fd5b610bc183610b08565b946020939093013593505050565b600060208284031215610be0578081fd5b81518015158114610a0c578182fd5b600060208284031215610c00578081fd5b5035919050565b6001600160a01b0391909116815260200190565b6001600160a01b039384168152919092166020820152604081019190915260600190565b6001600160a01b03929092168252602082015260400190565b901515815260200190565b6000602080835283518082850152825b81811015610c8f57858101830151858201604001528201610c73565b81811115610ca05783604083870101525b50601f01601f1916929092016040019392505050565b90815260200190565b60ff9190911681526020019056fe45524332303a207472616e7366657220746f20746865207a65726f206164647265737345524332303a20617070726f766520746f20746865207a65726f206164647265737345524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e636545524332303a207472616e7366657220616d6f756e74206578636565647320616c6c6f77616e636545524332303a207472616e736665722066726f6d20746865207a65726f206164647265737345524332303a20617070726f76652066726f6d20746865207a65726f206164647265737345524332303a2064656372656173656420616c6c6f77616e63652062656c6f77207a65726fa264697066735822122069a94b4190fe4204521f185375175472fa777f0f59edf162489bbd1a5bdb764264736f6c63430007060033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/abis/BalancerGaugeController.json b/apps/api/src/app/contracts/abis/BalancerGaugeController.json new file mode 100644 index 000000000..ac3ffebdb --- /dev/null +++ b/apps/api/src/app/contracts/abis/BalancerGaugeController.json @@ -0,0 +1,26 @@ +{ + "abi": [ + { + "constant": true, + "inputs": [ + { + "internalType": "address", + "name": "addr", + "type": "address" + } + ], + "name": "gauge_relative_weight", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + } + ], + "bytecode": "" +} diff --git a/apps/api/src/app/contracts/abis/BalancerMinter.json b/apps/api/src/app/contracts/abis/BalancerMinter.json new file mode 100644 index 000000000..41f4bc83c --- /dev/null +++ b/apps/api/src/app/contracts/abis/BalancerMinter.json @@ -0,0 +1,73 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "BalancerMinter", + "sourceName": "contracts/mock/BalancerMinter.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "contract IERC20", + "name": "_balToken", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "balToken", + "outputs": [ + { + "internalType": "contract IERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "gaugeMultiplier", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "gauge", + "type": "address" + } + ], + "name": "mint", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x60a060405234801561001057600080fd5b5060405161031e38038061031e83398101604081905261002f91610040565b6001600160a01b0316608052610070565b60006020828403121561005257600080fd5b81516001600160a01b038116811461006957600080fd5b9392505050565b60805161028d61009160003960008181604b0152610183015261028d6000f3fe608060405234801561001057600080fd5b50600436106100415760003560e01c806338d54645146100465780636a6278421461008a578063f74f5a75146100ab575b600080fd5b61006d7f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020015b60405180910390f35b61009d6100983660046101e9565b6100cb565b604051908152602001610081565b61009d6100b93660046101e9565b60006020819052908152604090205481565b60006100d782336100dd565b92915050565b6040516370a0823160e01b81526001600160a01b03828116600483015260009182918516906370a0823190602401602060405180830381865afa158015610128573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061014c9190610219565b9050600a61015a8183610232565b6040516340c10f1960e01b81526001600160a01b038681166004830152602482018390529194507f0000000000000000000000000000000000000000000000000000000000000000909116906340c10f1990604401600060405180830381600087803b1580156101c957600080fd5b505af11580156101dd573d6000803e3d6000fd5b50505050505092915050565b6000602082840312156101fb57600080fd5b81356001600160a01b038116811461021257600080fd5b9392505050565b60006020828403121561022b57600080fd5b5051919050565b80820281158282048414176100d757634e487b7160e01b600052601160045260246000fdfea26469706673582212202629fbd885bb989433d9b6bb75ed570fa59f837050fe85071e7afb8b8e38dc1d64736f6c63430008120033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100415760003560e01c806338d54645146100465780636a6278421461008a578063f74f5a75146100ab575b600080fd5b61006d7f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020015b60405180910390f35b61009d6100983660046101e9565b6100cb565b604051908152602001610081565b61009d6100b93660046101e9565b60006020819052908152604090205481565b60006100d782336100dd565b92915050565b6040516370a0823160e01b81526001600160a01b03828116600483015260009182918516906370a0823190602401602060405180830381865afa158015610128573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061014c9190610219565b9050600a61015a8183610232565b6040516340c10f1960e01b81526001600160a01b038681166004830152602482018390529194507f0000000000000000000000000000000000000000000000000000000000000000909116906340c10f1990604401600060405180830381600087803b1580156101c957600080fd5b505af11580156101dd573d6000803e3d6000fd5b50505050505092915050565b6000602082840312156101fb57600080fd5b81356001600160a01b038116811461021257600080fd5b9392505050565b60006020828403121561022b57600080fd5b5051919050565b80820281158282048414176100d757634e487b7160e01b600052601160045260246000fdfea26469706673582212202629fbd885bb989433d9b6bb75ed570fa59f837050fe85071e7afb8b8e38dc1d64736f6c63430008120033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/abis/BalancerVault.json b/apps/api/src/app/contracts/abis/BalancerVault.json new file mode 100644 index 000000000..58d527d12 --- /dev/null +++ b/apps/api/src/app/contracts/abis/BalancerVault.json @@ -0,0 +1,121 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "BalancerVault", + "sourceName": "contracts/mock/BalancerVault.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "_bpt", + "type": "address" + }, + { + "internalType": "address", + "name": "_usdc", + "type": "address" + }, + { + "internalType": "address", + "name": "_thx", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "bpt", + "outputs": [ + { + "internalType": "contract ERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "poolId", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "components": [ + { + "internalType": "address[]", + "name": "assets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "maxAmountsIn", + "type": "uint256[]" + }, + { + "internalType": "bytes", + "name": "userData", + "type": "bytes" + }, + { + "internalType": "bool", + "name": "fromInternalBalance", + "type": "bool" + } + ], + "internalType": "struct BalancerVault.JoinPoolRequest", + "name": "request", + "type": "tuple" + } + ], + "name": "joinPool", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "thx", + "outputs": [ + { + "internalType": "contract ERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "usdc", + "outputs": [ + { + "internalType": "contract ERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + } + ], + "bytecode": "0x608060405234801561001057600080fd5b50604051610a7a380380610a7a83398101604081905261002f9161008d565b600080546001600160a01b039485166001600160a01b0319918216179091556001805493851693821693909317909255600280549190931691161790556100cf565b80516001600160a01b038116811461008857600080fd5b919050565b6000806000606084860312156100a1578283fd5b6100aa84610071565b92506100b860208501610071565b91506100c660408501610071565b90509250925092565b61099c806100de6000396000f3fe608060405234801561001057600080fd5b506004361061004c5760003560e01c80633e413bee14610051578063546af3c31461006f578063a81d22e214610077578063b95cac281461007f575b600080fd5b610059610094565b604051610066919061089c565b60405180910390f35b6100596100a3565b6100596100b2565b61009261008d366004610751565b6100c1565b005b6001546001600160a01b031681565b6000546001600160a01b031681565b6002546001600160a01b031681565b600154602082015180516001600160a01b03909216916323b872dd9186913091906000906100eb57fe5b60200260200101516040518463ffffffff1660e01b81526004016101119392919061085f565b602060405180830381600087803b15801561012b57600080fd5b505af115801561013f573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906101639190610735565b50600254602082015180516001600160a01b03909216916323b872dd918691309190600190811061019057fe5b60200260200101516040518463ffffffff1660e01b81526004016101b69392919061085f565b602060405180830381600087803b1580156101d057600080fd5b505af11580156101e4573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102089190610735565b5060006102c1600160009054906101000a90046001600160a01b03166001600160a01b031663313ce5676040518163ffffffff1660e01b815260040160206040518083038186803b15801561025c57600080fd5b505afa158015610270573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610294919061083e565b60ff16600a0a83602001516000815181106102ab57fe5b60200260200101516104b990919063ffffffff16565b90506000610365600260009054906101000a90046001600160a01b03166001600160a01b031663313ce5676040518163ffffffff1660e01b815260040160206040518083038186803b15801561031657600080fd5b505afa15801561032a573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061034e919061083e565b60ff16600a0a84602001516001815181106102ab57fe5b9050600061040660008054906101000a90046001600160a01b03166001600160a01b031663313ce5676040518163ffffffff1660e01b815260040160206040518083038186803b1580156103b857600080fd5b505afa1580156103cc573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906103f0919061083e565b60ff16600a0a6104008585610522565b90610583565b60005460405163a9059cbb60e01b81529192506001600160a01b03169063a9059cbb906104399088908590600401610883565b602060405180830381600087803b15801561045357600080fd5b505af1158015610467573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061048b9190610735565b6104b05760405162461bcd60e51b81526004016104a7906108b0565b60405180910390fd5b50505050505050565b600080821161050f576040805162461bcd60e51b815260206004820152601a60248201527f536166654d6174683a206469766973696f6e206279207a65726f000000000000604482015290519081900360640190fd5b81838161051857fe5b0490505b92915050565b60008282018381101561057c576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b6000826105925750600061051c565b8282028284828161059f57fe5b041461057c5760405162461bcd60e51b81526004018080602001828103825260218152602001806109466021913960400191505060405180910390fd5b80356001600160a01b03811681146105f357600080fd5b919050565b600082601f830112610608578081fd5b8135602061061d61061883610916565b6108f2565b8281528181019085830183850287018401881015610639578586fd5b855b8581101561065e5761064c826105dc565b8452928401929084019060010161063b565b5090979650505050505050565b600082601f83011261067b578081fd5b8135602061068b61061883610916565b82815281810190858301838502870184018810156106a7578586fd5b855b8581101561065e578135845292840192908401906001016106a9565b80356105f381610934565b600082601f8301126106e0578081fd5b813567ffffffffffffffff8111156106f457fe5b610707601f8201601f19166020016108f2565b81815284602083860101111561071b578283fd5b816020850160208301379081016020019190915292915050565b600060208284031215610746578081fd5b815161057c81610934565b60008060008060808587031215610766578283fd5b84359350610776602086016105dc565b9250610784604086016105dc565b9150606085013567ffffffffffffffff808211156107a0578283fd5b90860190608082890312156107b3578283fd5b6107bd60806108f2565b8235828111156107cb578485fd5b6107d78a8286016105f8565b8252506020830135828111156107eb578485fd5b6107f78a82860161066b565b60208301525060408301358281111561080e578485fd5b61081a8a8286016106d0565b60408301525061082c606084016106c5565b60608201529598949750929550505050565b60006020828403121561084f578081fd5b815160ff8116811461057c578182fd5b6001600160a01b039384168152919092166020820152604081019190915260600190565b6001600160a01b03929092168252602082015260400190565b6001600160a01b0391909116815260200190565b60208082526022908201527f42616c616e6365725661756c743a20425054207472616e73666572206661696c604082015261195960f21b606082015260800190565b60405181810167ffffffffffffffff8111828210171561090e57fe5b604052919050565b600067ffffffffffffffff82111561092a57fe5b5060209081020190565b801515811461094257600080fd5b5056fe536166654d6174683a206d756c7469706c69636174696f6e206f766572666c6f77a26469706673582212200da6e89226c943a50bf8e722c06434037f552da74481e18a14dd1d7970f5716d64736f6c63430007060033", + "deployedBytecode": "0x608060405234801561001057600080fd5b506004361061004c5760003560e01c80633e413bee14610051578063546af3c31461006f578063a81d22e214610077578063b95cac281461007f575b600080fd5b610059610094565b604051610066919061089c565b60405180910390f35b6100596100a3565b6100596100b2565b61009261008d366004610751565b6100c1565b005b6001546001600160a01b031681565b6000546001600160a01b031681565b6002546001600160a01b031681565b600154602082015180516001600160a01b03909216916323b872dd9186913091906000906100eb57fe5b60200260200101516040518463ffffffff1660e01b81526004016101119392919061085f565b602060405180830381600087803b15801561012b57600080fd5b505af115801561013f573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906101639190610735565b50600254602082015180516001600160a01b03909216916323b872dd918691309190600190811061019057fe5b60200260200101516040518463ffffffff1660e01b81526004016101b69392919061085f565b602060405180830381600087803b1580156101d057600080fd5b505af11580156101e4573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102089190610735565b5060006102c1600160009054906101000a90046001600160a01b03166001600160a01b031663313ce5676040518163ffffffff1660e01b815260040160206040518083038186803b15801561025c57600080fd5b505afa158015610270573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610294919061083e565b60ff16600a0a83602001516000815181106102ab57fe5b60200260200101516104b990919063ffffffff16565b90506000610365600260009054906101000a90046001600160a01b03166001600160a01b031663313ce5676040518163ffffffff1660e01b815260040160206040518083038186803b15801561031657600080fd5b505afa15801561032a573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061034e919061083e565b60ff16600a0a84602001516001815181106102ab57fe5b9050600061040660008054906101000a90046001600160a01b03166001600160a01b031663313ce5676040518163ffffffff1660e01b815260040160206040518083038186803b1580156103b857600080fd5b505afa1580156103cc573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906103f0919061083e565b60ff16600a0a6104008585610522565b90610583565b60005460405163a9059cbb60e01b81529192506001600160a01b03169063a9059cbb906104399088908590600401610883565b602060405180830381600087803b15801561045357600080fd5b505af1158015610467573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061048b9190610735565b6104b05760405162461bcd60e51b81526004016104a7906108b0565b60405180910390fd5b50505050505050565b600080821161050f576040805162461bcd60e51b815260206004820152601a60248201527f536166654d6174683a206469766973696f6e206279207a65726f000000000000604482015290519081900360640190fd5b81838161051857fe5b0490505b92915050565b60008282018381101561057c576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b6000826105925750600061051c565b8282028284828161059f57fe5b041461057c5760405162461bcd60e51b81526004018080602001828103825260218152602001806109466021913960400191505060405180910390fd5b80356001600160a01b03811681146105f357600080fd5b919050565b600082601f830112610608578081fd5b8135602061061d61061883610916565b6108f2565b8281528181019085830183850287018401881015610639578586fd5b855b8581101561065e5761064c826105dc565b8452928401929084019060010161063b565b5090979650505050505050565b600082601f83011261067b578081fd5b8135602061068b61061883610916565b82815281810190858301838502870184018810156106a7578586fd5b855b8581101561065e578135845292840192908401906001016106a9565b80356105f381610934565b600082601f8301126106e0578081fd5b813567ffffffffffffffff8111156106f457fe5b610707601f8201601f19166020016108f2565b81815284602083860101111561071b578283fd5b816020850160208301379081016020019190915292915050565b600060208284031215610746578081fd5b815161057c81610934565b60008060008060808587031215610766578283fd5b84359350610776602086016105dc565b9250610784604086016105dc565b9150606085013567ffffffffffffffff808211156107a0578283fd5b90860190608082890312156107b3578283fd5b6107bd60806108f2565b8235828111156107cb578485fd5b6107d78a8286016105f8565b8252506020830135828111156107eb578485fd5b6107f78a82860161066b565b60208301525060408301358281111561080e578485fd5b61081a8a8286016106d0565b60408301525061082c606084016106c5565b60608201529598949750929550505050565b60006020828403121561084f578081fd5b815160ff8116811461057c578182fd5b6001600160a01b039384168152919092166020820152604081019190915260600190565b6001600160a01b03929092168252602082015260400190565b6001600160a01b0391909116815260200190565b60208082526022908201527f42616c616e6365725661756c743a20425054207472616e73666572206661696c604082015261195960f21b606082015260800190565b60405181810167ffffffffffffffff8111828210171561090e57fe5b604052919050565b600067ffffffffffffffff82111561092a57fe5b5060209081020190565b801515811461094257600080fd5b5056fe536166654d6174683a206d756c7469706c69636174696f6e206f766572666c6f77a26469706673582212200da6e89226c943a50bf8e722c06434037f552da74481e18a14dd1d7970f5716d64736f6c63430007060033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/abis/Launchpad.json b/apps/api/src/app/contracts/abis/Launchpad.json new file mode 100644 index 000000000..ad0bb3f89 --- /dev/null +++ b/apps/api/src/app/contracts/abis/Launchpad.json @@ -0,0 +1,183 @@ +{ + "_format": "hh-vyper-artifact-1", + "contractName": "Launchpad", + "sourceName": "contracts/Launchpad.vy", + "abi": [ + { + "name": "VESystemCreated", + "inputs": [ + { + "name": "token", + "type": "address", + "indexed": true + }, + { + "name": "votingEscrow", + "type": "address", + "indexed": false + }, + { + "name": "rewardDistributor", + "type": "address", + "indexed": false + }, + { + "name": "rewardFaucet", + "type": "address", + "indexed": false + }, + { + "name": "admin", + "type": "address", + "indexed": false + } + ], + "anonymous": false, + "type": "event" + }, + { + "stateMutability": "nonpayable", + "type": "constructor", + "inputs": [ + { + "name": "_votingEscrow", + "type": "address" + }, + { + "name": "_rewardDistributor", + "type": "address" + }, + { + "name": "_rewardFaucet", + "type": "address" + }, + { + "name": "_balToken", + "type": "address" + }, + { + "name": "_balMinter", + "type": "address" + } + ], + "outputs": [] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "deploy", + "inputs": [ + { + "name": "tokenBptAddr", + "type": "address" + }, + { + "name": "name", + "type": "string" + }, + { + "name": "symbol", + "type": "string" + }, + { + "name": "maxLockTime", + "type": "uint256" + }, + { + "name": "rewardDistributorStartTime", + "type": "uint256" + }, + { + "name": "admin_unlock_all", + "type": "address" + }, + { + "name": "admin_early_unlock", + "type": "address" + }, + { + "name": "rewardReceiver", + "type": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "address" + }, + { + "name": "", + "type": "address" + }, + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "votingEscrow", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "rewardDistributor", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "rewardFaucet", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "balToken", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "balMinter", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + } + ], + "bytecode": "0x60206106c76000396000518060a01c6106c25760405260206106e76000396000518060a01c6106c25760605260206107076000396000518060a01c6106c25760805260206107276000396000518060a01c6106c25760a05260206107476000396000518060a01c6106c25760c052346106c257604051156100b157606051156100aa57608051156100a35760a0511561009c5760c05115156100b4565b60006100b4565b60006100b4565b60006100b4565b60005b61011757600c60e0527f7a65726f206164647265737300000000000000000000000000000000000000006101005260e05060e0518061010001601f826000031636823750506308c379a060a052602060c052601f19601f60e051011660440160bcfd5b60405161057552606051610595526080516105b55260a0516105d55260c0516105f55261057561014c61000039610615610000f36003361161000c5761055d565b60003560e01c346105635763915c40938118610498576101443610610563576004358060a01c6105635760405260243560040160408135116105635780358060605260208201818160803750505060443560040160208135116105635780358060c05260208201803560e05250505060a4358060a01c610563576101005260c4358060a01c610563576101205260e4358060a01c610563576101405260405160206105d560003960005118610121576004610160527f2162616c000000000000000000000000000000000000000000000000000000006101805261016050610160518061018001601f826000031636823750506308c379a061012052602061014052601f19601f61016051011660440161013cfd5b7f602d3d8160093d39f3363d3d373d3d3d363d730000000000000000000000000061018052602061057560003960005160601b610193527f5af43d82803e903d91602b57fd5bf300000000000000000000000000000000006101a75260366101806000f0801561056357610160527f602d3d8160093d39f3363d3d373d3d3d363d73000000000000000000000000006101a052602061059560003960005160601b6101b3527f5af43d82803e903d91602b57fd5bf300000000000000000000000000000000006101c75260366101a06000f08015610563576101805260016101a052610140516101c0526101405161022257610180516101c05260006101a0525b61016051639bf6abf36101e052610180604051610200528061022052806102000160605180825260208201818183608060045afa5050508051806020830101601f82600003163682375050601f19601f825160200101169050810190508061024052806102000160c0518082526020820160e051815250508051806020830101601f82600003163682375050601f19601f8251602001011690508101905033610260526101005161028052610120516102a0526064356102c05260206105d56000396000516102e05260206105f5600039600051610300526101c051610320526101a05161034052610180516103605250803b156105635760006101e06102246101fc6000855af1610339573d600060003e3d6000fd5b507f602d3d8160093d39f3363d3d373d3d3d363d73000000000000000000000000006102005260206105b560003960005160601b610213527f5af43d82803e903d91602b57fd5bf300000000000000000000000000000000006102275260366102006000f08015610563576101e0526101805163be2030946102005261016051610220526101e05161024052608435610260523361028052803b15610563576000610200608461021c6000855af16103f6573d600060003e3d6000fd5b506101e05163c4d66de8610200526101805161022052803b15610563576000610200602461021c6000855af1610431573d600060003e3d6000fd5b506040517fa1de2bb5131ee0bd88e8ab94ac9f5ecf9e9d0bcea61a294eb7df4bf7c222b641610160516102005261018051610220526101e0516102405233610260526080610200a2610160516102005261018051610220526101e051610240526060610200f35b634f2bfe5b81186104bf576004361061056357602061057560003960005160405260206040f35b63acc2166a81186104e6576004361061056357602061059560003960005160405260206040f35b63397bcd41811861050d57600436106105635760206105b560003960005160405260206040f35b6338d54645811861053457600436106105635760206105d560003960005160405260206040f35b6373f43d6d811861055b57600436106105635760206105f560003960005160405260206040f35b505b60006000fd5b600080fda165767970657283000307000b005b600080fd", + "deployedBytecode": "0x6003361161000c5761055d565b60003560e01c346105635763915c40938118610498576101443610610563576004358060a01c6105635760405260243560040160408135116105635780358060605260208201818160803750505060443560040160208135116105635780358060c05260208201803560e05250505060a4358060a01c610563576101005260c4358060a01c610563576101205260e4358060a01c610563576101405260405160206105d560003960005118610121576004610160527f2162616c000000000000000000000000000000000000000000000000000000006101805261016050610160518061018001601f826000031636823750506308c379a061012052602061014052601f19601f61016051011660440161013cfd5b7f602d3d8160093d39f3363d3d373d3d3d363d730000000000000000000000000061018052602061057560003960005160601b610193527f5af43d82803e903d91602b57fd5bf300000000000000000000000000000000006101a75260366101806000f0801561056357610160527f602d3d8160093d39f3363d3d373d3d3d363d73000000000000000000000000006101a052602061059560003960005160601b6101b3527f5af43d82803e903d91602b57fd5bf300000000000000000000000000000000006101c75260366101a06000f08015610563576101805260016101a052610140516101c0526101405161022257610180516101c05260006101a0525b61016051639bf6abf36101e052610180604051610200528061022052806102000160605180825260208201818183608060045afa5050508051806020830101601f82600003163682375050601f19601f825160200101169050810190508061024052806102000160c0518082526020820160e051815250508051806020830101601f82600003163682375050601f19601f8251602001011690508101905033610260526101005161028052610120516102a0526064356102c05260206105d56000396000516102e05260206105f5600039600051610300526101c051610320526101a05161034052610180516103605250803b156105635760006101e06102246101fc6000855af1610339573d600060003e3d6000fd5b507f602d3d8160093d39f3363d3d373d3d3d363d73000000000000000000000000006102005260206105b560003960005160601b610213527f5af43d82803e903d91602b57fd5bf300000000000000000000000000000000006102275260366102006000f08015610563576101e0526101805163be2030946102005261016051610220526101e05161024052608435610260523361028052803b15610563576000610200608461021c6000855af16103f6573d600060003e3d6000fd5b506101e05163c4d66de8610200526101805161022052803b15610563576000610200602461021c6000855af1610431573d600060003e3d6000fd5b506040517fa1de2bb5131ee0bd88e8ab94ac9f5ecf9e9d0bcea61a294eb7df4bf7c222b641610160516102005261018051610220526101e0516102405233610260526080610200a2610160516102005261018051610220526101e051610240526060610200f35b634f2bfe5b81186104bf576004361061056357602061057560003960005160405260206040f35b63acc2166a81186104e6576004361061056357602061059560003960005160405260206040f35b63397bcd41811861050d57600436106105635760206105b560003960005160405260206040f35b6338d54645811861053457600436106105635760206105d560003960005160405260206040f35b6373f43d6d811861055b57600436106105635760206105f560003960005160405260206040f35b505b60006000fd5b600080fda165767970657283000307000b", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/abis/LensReward.json b/apps/api/src/app/contracts/abis/LensReward.json new file mode 100644 index 000000000..ccec8ce42 --- /dev/null +++ b/apps/api/src/app/contracts/abis/LensReward.json @@ -0,0 +1,93 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "LensReward", + "sourceName": "contracts/LensReward.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "contract IRewardDistributor", + "name": "distributor", + "type": "address" + }, + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "contract IERC20", + "name": "token", + "type": "address" + } + ], + "name": "getUserClaimableReward", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "claimableAmount", + "type": "uint256" + } + ], + "internalType": "struct LensReward.ClaimableRewards", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IRewardDistributor", + "name": "distributor", + "type": "address" + }, + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "contract IERC20[]", + "name": "tokens", + "type": "address[]" + } + ], + "name": "getUserClaimableRewardsAll", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "claimableAmount", + "type": "uint256" + } + ], + "internalType": "struct LensReward.ClaimableRewards[]", + "name": "", + "type": "tuple[]" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x608060405234801561001057600080fd5b50610552806100206000396000f3fe608060405234801561001057600080fd5b50600436106100365760003560e01c80635753bfca1461003b57806360491ff414610064575b600080fd5b61004e61004936600461030f565b610084565b60405161005b919061035a565b60405180910390f35b61007761007236600461037c565b610221565b60405161005b9190610411565b60408051808201909152600080825260208201526040516370a0823160e01b81526001600160a01b038481166004830152600091908416906370a0823190602401602060405180830381865afa1580156100e2573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906101069190610471565b60405163ca31879d60e01b81526001600160a01b03868116600483015285811660248301529192509086169063ca31879d906044016020604051808303816000875af115801561015a573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061017e9190610471565b506040516370a0823160e01b81526001600160a01b038581166004830152600091908516906370a0823190602401602060405180830381865afa1580156101c9573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906101ed9190610471565b90506040518060400160405280856001600160a01b03168152602001838361021591906104a0565b90529695505050505050565b60608160008167ffffffffffffffff81111561023f5761023f6104b3565b60405190808252806020026020018201604052801561028457816020015b604080518082019091526000808252602082015281526020019060019003908161025d5790505b50905060005b828110156102ec576102be88888888858181106102a9576102a96104c9565b905060200201602081019061004991906104df565b8282815181106102d0576102d06104c9565b6020026020010181905250806102e590610503565b905061028a565b509695505050505050565b6001600160a01b038116811461030c57600080fd5b50565b60008060006060848603121561032457600080fd5b833561032f816102f7565b9250602084013561033f816102f7565b9150604084013561034f816102f7565b809150509250925092565b81516001600160a01b0316815260208083015190820152604081015b92915050565b6000806000806060858703121561039257600080fd5b843561039d816102f7565b935060208501356103ad816102f7565b9250604085013567ffffffffffffffff808211156103ca57600080fd5b818701915087601f8301126103de57600080fd5b8135818111156103ed57600080fd5b8860208260051b850101111561040257600080fd5b95989497505060200194505050565b602080825282518282018190526000919060409081850190868401855b828110156104645761045484835180516001600160a01b03168252602090810151910152565b928401929085019060010161042e565b5091979650505050505050565b60006020828403121561048357600080fd5b5051919050565b634e487b7160e01b600052601160045260246000fd5b818103818111156103765761037661048a565b634e487b7160e01b600052604160045260246000fd5b634e487b7160e01b600052603260045260246000fd5b6000602082840312156104f157600080fd5b81356104fc816102f7565b9392505050565b6000600182016105155761051561048a565b506001019056fea264697066735822122027644c9f1cbb10d96d582d4eeb73f545853df009df64befe97d316d4df9f2cf264736f6c63430008120033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100365760003560e01c80635753bfca1461003b57806360491ff414610064575b600080fd5b61004e61004936600461030f565b610084565b60405161005b919061035a565b60405180910390f35b61007761007236600461037c565b610221565b60405161005b9190610411565b60408051808201909152600080825260208201526040516370a0823160e01b81526001600160a01b038481166004830152600091908416906370a0823190602401602060405180830381865afa1580156100e2573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906101069190610471565b60405163ca31879d60e01b81526001600160a01b03868116600483015285811660248301529192509086169063ca31879d906044016020604051808303816000875af115801561015a573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061017e9190610471565b506040516370a0823160e01b81526001600160a01b038581166004830152600091908516906370a0823190602401602060405180830381865afa1580156101c9573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906101ed9190610471565b90506040518060400160405280856001600160a01b03168152602001838361021591906104a0565b90529695505050505050565b60608160008167ffffffffffffffff81111561023f5761023f6104b3565b60405190808252806020026020018201604052801561028457816020015b604080518082019091526000808252602082015281526020019060019003908161025d5790505b50905060005b828110156102ec576102be88888888858181106102a9576102a96104c9565b905060200201602081019061004991906104df565b8282815181106102d0576102d06104c9565b6020026020010181905250806102e590610503565b905061028a565b509695505050505050565b6001600160a01b038116811461030c57600080fd5b50565b60008060006060848603121561032457600080fd5b833561032f816102f7565b9250602084013561033f816102f7565b9150604084013561034f816102f7565b809150509250925092565b81516001600160a01b0316815260208083015190820152604081015b92915050565b6000806000806060858703121561039257600080fd5b843561039d816102f7565b935060208501356103ad816102f7565b9250604085013567ffffffffffffffff808211156103ca57600080fd5b818701915087601f8301126103de57600080fd5b8135818111156103ed57600080fd5b8860208260051b850101111561040257600080fd5b95989497505060200194505050565b602080825282518282018190526000919060409081850190868401855b828110156104645761045484835180516001600160a01b03168252602090810151910152565b928401929085019060010161042e565b5091979650505050505050565b60006020828403121561048357600080fd5b5051919050565b634e487b7160e01b600052601160045260246000fd5b818103818111156103765761037661048a565b634e487b7160e01b600052604160045260246000fd5b634e487b7160e01b600052603260045260246000fd5b6000602082840312156104f157600080fd5b81356104fc816102f7565b9392505050565b6000600182016105155761051561048a565b506001019056fea264697066735822122027644c9f1cbb10d96d582d4eeb73f545853df009df64befe97d316d4df9f2cf264736f6c63430008120033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/abis/LimitedSupplyToken.json b/apps/api/src/app/contracts/abis/LimitedSupplyToken.json new file mode 100644 index 000000000..18d4a2eb5 --- /dev/null +++ b/apps/api/src/app/contracts/abis/LimitedSupplyToken.json @@ -0,0 +1,298 @@ +[ + { + "inputs": [ + { + "internalType": "string", + "name": "_name", + "type": "string" + }, + { + "internalType": "string", + "name": "_symbol", + "type": "string" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256" + } + ], + "name": "increaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/apps/api/src/app/contracts/abis/NonFungibleToken.json b/apps/api/src/app/contracts/abis/NonFungibleToken.json new file mode 100644 index 000000000..a9e8c1adb --- /dev/null +++ b/apps/api/src/app/contracts/abis/NonFungibleToken.json @@ -0,0 +1,744 @@ +[ + { + "inputs": [ + { + "internalType": "string", + "name": "name_", + "type": "string" + }, + { + "internalType": "string", + "name": "symbol_", + "type": "string" + }, + { + "internalType": "string", + "name": "baseURI_", + "type": "string" + }, + { + "internalType": "address", + "name": "owner_", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "approved", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "ApprovalForAll", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "previousAdminRole", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "newAdminRole", + "type": "bytes32" + } + ], + "name": "RoleAdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleGranted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MINTER_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "baseURI", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "getApproved", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleAdmin", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "getRoleMember", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleMemberCount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "grantRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "hasRole", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "operator", + "type": "address" + } + ], + "name": "isApprovedForAll", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_recipient", + "type": "address" + }, + { + "internalType": "string", + "name": "_tokenURI", + "type": "string" + } + ], + "name": "mint", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "ownerOf", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "renounceRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "revokeRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "safeTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_data", + "type": "bytes" + } + ], + "name": "safeTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "setApprovalForAll", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "tokenByIndex", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "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" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "tokenURI", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/apps/api/src/app/contracts/abis/RewardDistributor.json b/apps/api/src/app/contracts/abis/RewardDistributor.json new file mode 100644 index 000000000..18ccb672a --- /dev/null +++ b/apps/api/src/app/contracts/abis/RewardDistributor.json @@ -0,0 +1,670 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "RewardDistributor", + "sourceName": "contracts/RewardDistributor.sol", + "abi": [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "newAdmin", + "type": "address" + } + ], + "name": "NewAdmin", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "enabled", + "type": "bool" + } + ], + "name": "OnlyCallerOptIn", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "contract IERC20", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "RewardDeposit", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "TokenAdded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "contract IERC20", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "lastCheckpointTimestamp", + "type": "uint256" + } + ], + "name": "TokenCheckpointed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "contract IERC20", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "userTokenTimeCursor", + "type": "uint256" + } + ], + "name": "TokensClaimed", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "tokens", + "type": "address[]" + } + ], + "name": "addAllowedRewardTokens", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "admin", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "allowedRewardTokens", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "checkpoint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IERC20", + "name": "token", + "type": "address" + } + ], + "name": "checkpointToken", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IERC20[]", + "name": "tokens", + "type": "address[]" + } + ], + "name": "checkpointTokens", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "checkpointUser", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "contract IERC20", + "name": "token", + "type": "address" + } + ], + "name": "claimToken", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "contract IERC20[]", + "name": "tokens", + "type": "address[]" + } + ], + "name": "claimTokens", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IERC20", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "depositToken", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IERC20[]", + "name": "tokens", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "amounts", + "type": "uint256[]" + } + ], + "name": "depositTokens", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IERC20", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "faucetDepositToken", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "getAllowedRewardTokens", + "outputs": [ + { + "internalType": "address[]", + "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getDomainSeparator", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "getNextNonce", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getTimeCursor", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IERC20", + "name": "token", + "type": "address" + } + ], + "name": "getTokenLastBalance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IERC20", + "name": "token", + "type": "address" + } + ], + "name": "getTokenTimeCursor", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IERC20", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "timestamp", + "type": "uint256" + } + ], + "name": "getTokensDistributedInWeek", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "timestamp", + "type": "uint256" + } + ], + "name": "getTotalSupplyAtTimestamp", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "timestamp", + "type": "uint256" + } + ], + "name": "getUserBalanceAtTimestamp", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "getUserTimeCursor", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "contract IERC20", + "name": "token", + "type": "address" + } + ], + "name": "getUserTokenTimeCursor", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getVotingEscrow", + "outputs": [ + { + "internalType": "contract IVotingEscrow", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IVotingEscrow", + "name": "votingEscrow", + "type": "address" + }, + { + "internalType": "contract IRewardFaucet", + "name": "rewardFaucet_", + "type": "address" + }, + { + "internalType": "uint256", + "name": "startTime", + "type": "uint256" + }, + { + "internalType": "address", + "name": "admin_", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "isInitialized", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "isOnlyCallerEnabled", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "rewardFaucet", + "outputs": [ + { + "internalType": "contract IRewardFaucet", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bool", + "name": "enabled", + "type": "bool" + } + ], + "name": "setOnlyCallerCheck", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "bool", + "name": "enabled", + "type": "bool" + }, + { + "internalType": "bytes", + "name": "signature", + "type": "bytes" + } + ], + "name": "setOnlyCallerCheckWithSignature", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newAdmin", + "type": "address" + } + ], + "name": "transferAdmin", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x60e060405234801561001057600080fd5b5060408051808201825260118152702932bbb0b9322234b9ba3934b13aba37b960791b602080830191825283518085019094526001808552603160f81b9185019182529251909120608052915190912060a0527f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f60c05260025560805160a05160c051612b476100b660003980611d9e525080611de0525080611dbf5250612b476000f3fe608060405234801561001057600080fd5b50600436106101e55760003560e01c806390193b7c1161010f578063de681faf116100a2578063ed3a088711610071578063ed3a0887146103ea578063f213bd8c146103fd578063f851a44014610410578063fcaa54ee14610418576101e5565b8063de681faf146103a7578063e811f44b146103ba578063ece7514d146103cd578063ed24911d146103e2576101e5565b8063be203094116100de578063be20309414610366578063c2c4c5c114610379578063ca31879d14610381578063d3dc4ca114610394576101e5565b806390193b7c1461031a578063905d10ac1461032d578063a1648aa514610340578063acbc142814610353576101e5565b80634c9a47d8116101875780638050a7ee116101565780638050a7ee146102cc57806382aa5ad4146102df578063876e69a1146102e757806388720467146102fa576101e5565b80634c9a47d8146102805780634f3c50901461029357806375829def146102a65780637b8d6221146102b9576101e5565b8063338b5dea116101c3578063338b5dea1461023d5780633902b9bc14610250578063392e53cd14610263578063397bcd4114610278576101e5565b806308b0308a146101ea57806314866e08146102085780632308805b1461021d575b600080fd5b6101f261042b565b6040516101ff91906127b8565b60405180910390f35b61021b61021636600461248e565b61043f565b005b61023061022b36600461248e565b61045b565b6040516101ff9190612892565b61021b61024b3660046125e0565b61048a565b61021b61025e36600461248e565b610543565b61026b61058e565b6040516101ff9190612887565b6101f2610597565b61021b61028e3660046125e0565b6105a6565b6102306102a1366004612788565b610633565b61021b6102b436600461248e565b610645565b61021b6102c736600461264a565b6106df565b6102306102da3660046125a8565b61087e565b610230610893565b6102306102f536600461248e565b610899565b61030d6103083660046124aa565b6108c4565b6040516101ff919061284f565b61023061032836600461248e565b610a87565b61021b61033b36600461260b565b610aa2565b61026b61034e36600461248e565b610b35565b61023061036136600461248e565b610b53565b61021b6103743660046126cc565b610b7e565b61021b610d45565b61023061038f3660046125a8565b610d5f565b6102306103a23660046125e0565b610e41565b6102306103b53660046125e0565b610e69565b61021b6103c83660046126b2565b610e91565b6103d5610e9b565b6040516101ff919061280e565b610230610efd565b61026b6103f836600461248e565b610f0c565b61021b61040b36600461260b565b610f21565b6101f26110bc565b61021b6104263660046124fc565b6110cb565b60035461010090046001600160a01b031690565b610447611159565b61045081611170565b610458611566565b50565b6001600160a01b0381166000908152600b6020526040902054600160801b90046001600160801b03165b919050565b610492611159565b6001600160a01b0382166000908152600a602052604090205460ff166104d35760405162461bcd60e51b81526004016104ca906128e3565b60405180910390fd5b6104de82600061156d565b6104f36001600160a01b0383163330846118e8565b6104fe82600161156d565b7f95bf5847357310d24f8d03d8bad76c8ee329dfd3a3cb200df21c7bd1619e93bd828260405161052f9291906127f5565b60405180910390a161053f611566565b5050565b61054b611159565b6001600160a01b0381166000908152600a602052604090205460ff166105835760405162461bcd60e51b81526004016104ca906128e3565b61045081600161156d565b60035460ff1681565b6004546001600160a01b031681565b6001600160a01b0382166000908152600a602052604090205460ff166105de5760405162461bcd60e51b81526004016104ca906128e3565b6004546001600160a01b031633146106085760405162461bcd60e51b81526004016104ca9061297b565b61061382600061156d565b6106286001600160a01b0383163330846118e8565b61053f82600161156d565b60009081526007602052604090205490565b6008546001600160a01b0316331461066f5760405162461bcd60e51b81526004016104ca90612ab6565b6001600160a01b0381166106955760405162461bcd60e51b81526004016104ca90612a1c565b600880546001600160a01b0319166001600160a01b0383169081179091556040517f71614071b88dee5e0b2ae578a9dd7b2ebbe9ae832ba419dc0242cd065a290b6c90600090a250565b6106e7611159565b6106f18382611942565b8260005b8181101561086e57600a600087878481811061070d57fe5b9050602002016020810190610722919061248e565b6001600160a01b0316815260208101919091526040016000205460ff1661075b5760405162461bcd60e51b81526004016104ca906128e3565b61078686868381811061076a57fe5b905060200201602081019061077f919061248e565b600061156d565b6107d0333086868581811061079757fe5b905060200201358989868181106107aa57fe5b90506020020160208101906107bf919061248e565b6001600160a01b03169291906118e8565b6107fb8686838181106107df57fe5b90506020020160208101906107f4919061248e565b600161156d565b7f95bf5847357310d24f8d03d8bad76c8ee329dfd3a3cb200df21c7bd1619e93bd86868381811061082857fe5b905060200201602081019061083d919061248e565b85858481811061084957fe5b9050602002013560405161085e9291906127f5565b60405180910390a16001016106f5565b5050610878611566565b50505050565b600061088a838361194f565b90505b92915050565b60065490565b6001600160a01b03166000908152600d6020526040902054600160401b90046001600160401b031690565b60606108ce611159565b836108d8816119cc565b6108e0611a04565b6108e985611170565b826000816001600160401b038111801561090257600080fd5b5060405190808252806020026020018201604052801561092c578160200160208202803683370190505b50905060005b82811015610a7357600a600088888481811061094a57fe5b905060200201602081019061095f919061248e565b6001600160a01b0316815260208101919091526040016000205460ff166109985760405162461bcd60e51b81526004016104ca906128e3565b6109a787878381811061076a57fe5b6109d1888888848181106109b757fe5b90506020020160208101906109cc919061248e565b611b58565b8282815181106109dd57fe5b60209081029190910101526004546001600160a01b031663c7b56abe888884818110610a0557fe5b9050602002016020810190610a1a919061248e565b6040518263ffffffff1660e01b8152600401610a3691906127b8565b600060405180830381600087803b158015610a5057600080fd5b505af1158015610a64573d6000803e3d6000fd5b50505050806001019050610932565b5092505050610a80611566565b9392505050565b6001600160a01b031660009081526020819052604090205490565b610aaa611159565b8060005b81811015610b2b57600a6000858584818110610ac657fe5b9050602002016020810190610adb919061248e565b6001600160a01b0316815260208101919091526040016000205460ff16610b145760405162461bcd60e51b81526004016104ca906128e3565b610b238484838181106107df57fe5b600101610aae565b505061053f611566565b6001600160a01b031660009081526001602052604090205460ff1690565b6001600160a01b03166000908152600b6020526040902054600160401b90046001600160401b031690565b60035460ff1615610ba15760405162461bcd60e51b81526004016104ca9061293c565b6003805460ff191660011790556001600160a01b03811615801590610bce57506001600160a01b03831615155b610bea5760405162461bcd60e51b81526004016104ca9061295c565b600880546001600160a01b038084166001600160a01b0319928316179092556004805486841692169190911790556003805491861661010002610100600160a81b0319909216919091179055610c3f82611d2a565b91506000610c4c42611d2a565b905080831015610c6e5760405162461bcd60e51b81526004016104ca906129e7565b80625c490001831115610c935760405162461bcd60e51b81526004016104ca90612a42565b80831415610d375760405163bd85b03960e01b81526000906001600160a01b0387169063bd85b03990610cca908590600401612892565b60206040518083038186803b158015610ce257600080fd5b505afa158015610cf6573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610d1a91906127a0565b11610d375760405162461bcd60e51b81526004016104ca90612a6e565b505060058190556006555050565b610d4d611159565b610d55611a04565b610d5d611566565b565b6000610d69611159565b82610d73816119cc565b6001600160a01b0383166000908152600a602052604090205460ff16610dab5760405162461bcd60e51b81526004016104ca906128e3565b610db3611a04565b610dbc84611170565b610dc783600061156d565b6000610dd38585611b58565b600480546040516363dab55f60e11b81529293506001600160a01b03169163c7b56abe91610e03918891016127b8565b600060405180830381600087803b158015610e1d57600080fd5b505af1158015610e31573d6000803e3d6000fd5b509294505050505061088d611566565b6001600160a01b03919091166000908152600c60209081526040808320938352929052205490565b6001600160a01b03919091166000908152600e60209081526040808320938352929052205490565b6104583382611d36565b60606009805480602002602001604051908101604052809291908181526020018280548015610ef357602002820191906000526020600020905b81546001600160a01b03168152600190910190602001808311610ed5575b5050505050905090565b6000610f07611d9a565b905090565b600a6020526000908152604090205460ff1681565b6008546001600160a01b03163314610f4b5760405162461bcd60e51b81526004016104ca90612ab6565b60005b818110156110b757600a6000848484818110610f6657fe5b9050602002016020810190610f7b919061248e565b6001600160a01b0316815260208101919091526040016000205460ff1615610fb55760405162461bcd60e51b81526004016104ca906128bc565b6001600a6000858585818110610fc757fe5b9050602002016020810190610fdc919061248e565b6001600160a01b031681526020810191909152604001600020805460ff1916911515919091179055600983838381811061101257fe5b9050602002016020810190611027919061248e565b81546001810183556000928352602090922090910180546001600160a01b0319166001600160a01b0390921691909117905582828281811061106557fe5b905060200201602081019061107a919061248e565b6001600160a01b03167f784c8f4dbf0ffedd6e72c76501c545a70f8b203b30a26ce542bf92ba87c248a460405160405180910390a2600101610f4e565b505050565b6008546001600160a01b031681565b60007fbd291ffccec065968fe20c5f8debdad73ab50837733f357eeae8814178015a9084846110f987610a87565b60405160200180858152602001846001600160a01b03168152602001831515815260200182815260200194505050505060405160208183030381529060405280519060200120905061114f8482846101f8611e58565b6108788484611d36565b61116a600280541415610190611e67565b60028055565b60035460405163010ae75760e01b815260009161010090046001600160a01b03169063010ae757906111a69085906004016127b8565b60206040518083038186803b1580156111be57600080fd5b505afa1580156111d2573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906111f691906127a0565b9050806112035750610458565b6001600160a01b0382166000908152600d6020526040812080549091600160401b9091046001600160401b0316908161124c5761124585600554600087611e75565b9050611289565b42821061125c5750505050610458565b508154600160801b90046001600160801b0316601481850311156112895761128685838387611e75565b90505b80611292575060015b6003546040516328d09d4760e01b815260009161010090046001600160a01b0316906328d09d47906112ca90899086906004016127f5565b60806040518083038186803b1580156112e257600080fd5b505afa1580156112f6573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061131a919061271e565b9050826113775760055442116113425760405162461bcd60e51b81526004016104ca906129a0565b61135a6005546113558360400151611f54565b611f64565b845467ffffffffffffffff19166001600160401b03821617855592505b61137f6123f6565b60005b6032811015611519578260400151851015801561139f5750868411155b1561147557600184019350829150868411156113e75760405180608001604052806000600f0b81526020016000600f0b81526020016000815260200160008152509250611470565b6003546040516328d09d4760e01b81526101009091046001600160a01b0316906328d09d479061141d908b9088906004016127f5565b60806040518083038186803b15801561143557600080fd5b505afa158015611449573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061146d919061271e565b92505b611511565b42851061148157611519565b6000826040015186039050600081846020015102600f0b8460000151600f0b136114ac5760006114bd565b81846020015102846000015103600f0b5b9050801580156114cc57508886115b156114e3576114da42611f54565b96505050611519565b6001600160a01b038a166000908152600e602090815260408083208a84529091529020555062093a80909401935b600101611382565b505083546001600160801b0316600019929092016001600160401b03908116600160801b029290921767ffffffffffffffff60401b1916600160401b939092169290920217909155505050565b6001600255565b6001600160a01b0382166000908152600b6020526040812080549091600160401b9091046001600160401b031690816115ee574291506115ac42611d2a565b835467ffffffffffffffff19166001600160401b039190911617835560055442116115e95760405162461bcd60e51b81526004016104ca906129a0565b611640565b81420390508361164057600061160383611d2a565b61160c42611d2a565b1490506000620151804261161f42611f54565b0310905081801561162e575080155b1561163d57505050505061053f565b50505b825467ffffffffffffffff60401b1916600160401b426001600160401b0316021783556040516370a0823160e01b81526000906001600160a01b038716906370a08231906116929030906004016127b8565b60206040518083038186803b1580156116aa57600080fd5b505afa1580156116be573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906116e291906127a0565b8454909150600090611705908390600160801b90046001600160801b0316611f70565b90508061171657505050505061053f565b6001600160801b0382111561173d5760405162461bcd60e51b81526004016104ca90612905565b600061174885611d2a565b6001600160a01b0389166000908152600c602052604081209192509081805b601481101561189f578462093a800193508342101561180a578715801561178d57508842145b1561179a578591506117ab565b878942038702816117a757fe5b0491505b6000858152602084905260409020546001600160801b03906117cd9084611f7e565b1161180557600085815260208490526040902080548301905589546001600160801b03600160801b808304821685018216029116178a555b61189f565b8715801561181757508884145b1561182457859150611835565b8789850387028161183157fe5b0491505b6000858152602084905260409020546001600160801b03906118579084611f7e565b1161188f57600085815260208490526040902080548301905589546001600160801b03600160801b808304821685018216029116178a555b9297508793508392600101611767565b507f9b7f1a85a4c9b4e59e1b6527d9969c50cdfb3a1a467d0c4a51fb0ed8bf07f1308b868a6040516118d39392919061289b565b60405180910390a15050505050505050505050565b604080516001600160a01b0380861660248301528416604482015260648082018490528251808303909101815260849091019091526020810180516001600160e01b03166323b872dd60e01b179052610878908590611f90565b61053f8183146067611e67565b6001600160a01b038083166000908152600f60209081526040808320938516835292905290812054801561198457905061088d565b6001600160a01b038085166000908152600d60209081526040808320549387168352600b9091529020546119c4916001600160401b039081169116611f64565b949350505050565b6001600160a01b03811660009081526001602052604090205460ff161561045857610458336001600160a01b03831614610191611e67565b6006546000611a1242611d2a565b905080821180611a2157504281145b15611a2d575050610d5d565b600360019054906101000a90046001600160a01b03166001600160a01b031663c2c4c5c16040518163ffffffff1660e01b8152600401600060405180830381600087803b158015611a7d57600080fd5b505af1158015611a91573d6000803e3d6000fd5b5050505060005b6014811015611b515781831115611aae57611b51565b60035460405163bd85b03960e01b81526101009091046001600160a01b03169063bd85b03990611ae2908690600401612892565b60206040518083038186803b158015611afa57600080fd5b505afa158015611b0e573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611b3291906127a0565b60008481526007602052604090205562093a8090920191600101611a98565b5050600655565b6001600160a01b0381166000908152600b6020526040812081611b7b858561194f565b6006546001600160a01b0387166000908152600d602052604081205492935091611be291611bc291611bbd9190600160401b90046001600160401b031661207a565b611f54565b8454611bdd90600160401b90046001600160401b0316611d2a565b61207a565b6001600160a01b038087166000908152600c60209081526040808320938b168352600e9091528120929350909190805b6014811015611c7e57848610611c2757611c7e565b600086815260076020526040902054611c3f57611c7e565b60008681526007602090815260408083205486835281842054928890529220540281611c6757fe5b62093a809790970196049190910190600101611c12565b506001600160a01b03808a166000908152600f60209081526040808320938c168352929052208590558015611d1e5785546001600160801b03600160801b80830482168490038216029116178655611ce06001600160a01b0389168a83612086565b7fff097c7d8b1957a4ff09ef1361b5fb54dcede3941ba836d0beb9d10bec725de689898388604051611d1594939291906127cc565b60405180910390a15b98975050505050505050565b62093a80908190040290565b6001600160a01b038216600081815260016020908152604091829020805460ff191685151590811790915582519384529083015280517fac9874a7a931a3f5c9f202c6d9cf40de5d21506993c9f9c38ca8265add89584c9281900390910190a15050565b60007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f0000000000000000000000000000000000000000000000000000000000000000611e076120d8565b3060405160200180868152602001858152602001848152602001838152602001826001600160a01b031681526020019550505050505060405160208183030381529060405280519060200120905090565b610878848484600019856120dc565b8161053f5761053f81612133565b60008282825b6080811015611f4857818310611e9057611f48565b6003546040516328d09d4760e01b81526002858501810104916000916101009091046001600160a01b0316906328d09d4790611ed2908d9086906004016127f5565b60806040518083038186803b158015611eea57600080fd5b505afa158015611efe573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611f22919061271e565b905088816040015111611f3757819450611f3e565b6001820393505b5050600101611e7b565b50909695505050505050565b600061088d62093a7f8301611d2a565b80820390821002900390565b600061088a83836001612143565b600082820161088a8482101583611e67565b600080836001600160a01b0316836040518082805190602001908083835b60208310611fcd5780518252601f199092019160209182019101611fae565b6001836020036101000a0380198251168184511680821785525050505050509050019150506000604051808303816000865af19150503d806000811461202f576040519150601f19603f3d011682016040523d82523d6000602084013e612034565b606091505b5091509150600082141561204c573d6000803e3d6000fd5b610878815160001480612072575081806020019051602081101561206f57600080fd5b50515b6101a2611e67565b80820390821102900390565b604080516001600160a01b038416602482015260448082018490528251808303909101815260649091019091526020810180516001600160e01b031663a9059cbb60e01b1790526110b7908490611f90565b4690565b60006120e785612159565b90506120fd6120f78783876121a5565b83611e67565b61210c428410156101b8611e67565b5050506001600160a01b039092166000908152602081905260409020805460010190555050565b610458816210905360ea1b6122c3565b60006121528484111583611e67565b5050900390565b6000612163611d9a565b82604051602001808061190160f01b81525060020183815260200182815260200192505050604051602081830303815290604052805190602001209050919050565b60006121b9846001600160a01b0316612324565b156122b15760408051630b135d3f60e11b808252600482018681526024830193845285516044840152855191936001600160a01b03891693631626ba7e938993899390929091606490910190602085019080838360005b83811015612228578181015183820152602001612210565b50505050905090810190601f1680156122555780820380516001836020036101000a031916815260200191505b50935050505060206040518083038186803b15801561227357600080fd5b505afa158015612287573d6000803e3d6000fd5b505050506040513d602081101561229d57600080fd5b50516001600160e01b031916149050610a80565b6122bc84848461232a565b9050610a80565b62461bcd60e51b600090815260206004526007602452600a808404818106603090810160081b958390069590950190829004918206850160101b01602363ffffff0060e086901c160160181b0190930160c81b60445260e882901c90606490fd5b3b151590565b600061233c82516041146101b9611e67565b60008060006020850151925060408501519150606085015160001a9050600060018783868660405160008152602001604052604051808581526020018460ff1681526020018381526020018281526020019450505050506020604051602081039080840390855afa1580156123b5573d6000803e3d6000fd5b5050604051601f1901519150506001600160a01b03811615801590611d1e5750876001600160a01b0316816001600160a01b03161498975050505050505050565b60405180608001604052806000600f0b81526020016000600f0b815260200160008152602001600081525090565b60008083601f840112612435578081fd5b5081356001600160401b0381111561244b578182fd5b602083019150836020808302850101111561246557600080fd5b9250929050565b8035801515811461048557600080fd5b8051600f81900b811461048557600080fd5b60006020828403121561249f578081fd5b813561088a81612afc565b6000806000604084860312156124be578182fd5b83356124c981612afc565b925060208401356001600160401b038111156124e3578283fd5b6124ef86828701612424565b9497909650939450505050565b600080600060608486031215612510578283fd5b833561251b81612afc565b9250602061252a85820161246c565b925060408501356001600160401b0380821115612545578384fd5b818701915087601f830112612558578384fd5b81358181111561256457fe5b612576601f8201601f19168501612ad9565b9150808252888482850101111561258b578485fd5b808484018584013784848284010152508093505050509250925092565b600080604083850312156125ba578182fd5b82356125c581612afc565b915060208301356125d581612afc565b809150509250929050565b600080604083850312156125f2578182fd5b82356125fd81612afc565b946020939093013593505050565b6000806020838503121561261d578182fd5b82356001600160401b03811115612632578283fd5b61263e85828601612424565b90969095509350505050565b6000806000806040858703121561265f578081fd5b84356001600160401b0380821115612675578283fd5b61268188838901612424565b90965094506020870135915080821115612699578283fd5b506126a687828801612424565b95989497509550505050565b6000602082840312156126c3578081fd5b61088a8261246c565b600080600080608085870312156126e1578182fd5b84356126ec81612afc565b935060208501356126fc81612afc565b925060408501359150606085013561271381612afc565b939692955090935050565b60006080828403121561272f578081fd5b604051608081018181106001600160401b038211171561274b57fe5b6040526127578361247c565b81526127656020840161247c565b602082015260408301516040820152606083015160608201528091505092915050565b600060208284031215612799578081fd5b5035919050565b6000602082840312156127b1578081fd5b5051919050565b6001600160a01b0391909116815260200190565b6001600160a01b0394851681529290931660208301526040820152606081019190915260800190565b6001600160a01b03929092168252602082015260400190565b6020808252825182820181905260009190848201906040850190845b81811015611f485783516001600160a01b03168352928401929184019160010161282a565b6020808252825182820181905260009190848201906040850190845b81811015611f485783518352928401929184019160010161286b565b901515815260200190565b90815260200190565b6001600160a01b039390931683526020830191909152604082015260600190565b6020808252600d908201526c185b1c9958591e48195e1a5cdd609a1b604082015260600190565b60208082526008908201526708585b1b1bddd95960c21b604082015260600190565b6020808252601e908201527f4d6178696d756d20746f6b656e2062616c616e63652065786365656465640000604082015260600190565b60208082526006908201526521747769636560d01b604082015260600190565b602080825260059082015264217a65726f60d81b604082015260600190565b6020808252600b908201526a1bdb9b1e4819985d58d95d60aa1b604082015260600190565b60208082526027908201527f52657761726420646973747269627574696f6e20686173206e6f7420737461726040820152661d1959081e595d60ca1b606082015260800190565b6020808252818101527f43616e6e6f74207374617274206265666f72652063757272656e74207765656b604082015260600190565b6020808252600c908201526b7a65726f206164647265737360a01b604082015260600190565b6020808252601290820152710626040eecacad6e640c8cad8c2f240dac2f60731b604082015260600190565b60208082526028908201527f5a65726f20746f74616c20737570706c7920726573756c747320696e206c6f736040820152677420746f6b656e7360c01b606082015260800190565b6020808252600990820152683737ba1030b236b4b760b91b604082015260600190565b6040518181016001600160401b0381118282101715612af457fe5b604052919050565b6001600160a01b038116811461045857600080fdfea2646970667358221220972c2bb8ecdbe63efa080ae50f636a301051bc328845b00b90454df26829a68764736f6c63430007060033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106101e55760003560e01c806390193b7c1161010f578063de681faf116100a2578063ed3a088711610071578063ed3a0887146103ea578063f213bd8c146103fd578063f851a44014610410578063fcaa54ee14610418576101e5565b8063de681faf146103a7578063e811f44b146103ba578063ece7514d146103cd578063ed24911d146103e2576101e5565b8063be203094116100de578063be20309414610366578063c2c4c5c114610379578063ca31879d14610381578063d3dc4ca114610394576101e5565b806390193b7c1461031a578063905d10ac1461032d578063a1648aa514610340578063acbc142814610353576101e5565b80634c9a47d8116101875780638050a7ee116101565780638050a7ee146102cc57806382aa5ad4146102df578063876e69a1146102e757806388720467146102fa576101e5565b80634c9a47d8146102805780634f3c50901461029357806375829def146102a65780637b8d6221146102b9576101e5565b8063338b5dea116101c3578063338b5dea1461023d5780633902b9bc14610250578063392e53cd14610263578063397bcd4114610278576101e5565b806308b0308a146101ea57806314866e08146102085780632308805b1461021d575b600080fd5b6101f261042b565b6040516101ff91906127b8565b60405180910390f35b61021b61021636600461248e565b61043f565b005b61023061022b36600461248e565b61045b565b6040516101ff9190612892565b61021b61024b3660046125e0565b61048a565b61021b61025e36600461248e565b610543565b61026b61058e565b6040516101ff9190612887565b6101f2610597565b61021b61028e3660046125e0565b6105a6565b6102306102a1366004612788565b610633565b61021b6102b436600461248e565b610645565b61021b6102c736600461264a565b6106df565b6102306102da3660046125a8565b61087e565b610230610893565b6102306102f536600461248e565b610899565b61030d6103083660046124aa565b6108c4565b6040516101ff919061284f565b61023061032836600461248e565b610a87565b61021b61033b36600461260b565b610aa2565b61026b61034e36600461248e565b610b35565b61023061036136600461248e565b610b53565b61021b6103743660046126cc565b610b7e565b61021b610d45565b61023061038f3660046125a8565b610d5f565b6102306103a23660046125e0565b610e41565b6102306103b53660046125e0565b610e69565b61021b6103c83660046126b2565b610e91565b6103d5610e9b565b6040516101ff919061280e565b610230610efd565b61026b6103f836600461248e565b610f0c565b61021b61040b36600461260b565b610f21565b6101f26110bc565b61021b6104263660046124fc565b6110cb565b60035461010090046001600160a01b031690565b610447611159565b61045081611170565b610458611566565b50565b6001600160a01b0381166000908152600b6020526040902054600160801b90046001600160801b03165b919050565b610492611159565b6001600160a01b0382166000908152600a602052604090205460ff166104d35760405162461bcd60e51b81526004016104ca906128e3565b60405180910390fd5b6104de82600061156d565b6104f36001600160a01b0383163330846118e8565b6104fe82600161156d565b7f95bf5847357310d24f8d03d8bad76c8ee329dfd3a3cb200df21c7bd1619e93bd828260405161052f9291906127f5565b60405180910390a161053f611566565b5050565b61054b611159565b6001600160a01b0381166000908152600a602052604090205460ff166105835760405162461bcd60e51b81526004016104ca906128e3565b61045081600161156d565b60035460ff1681565b6004546001600160a01b031681565b6001600160a01b0382166000908152600a602052604090205460ff166105de5760405162461bcd60e51b81526004016104ca906128e3565b6004546001600160a01b031633146106085760405162461bcd60e51b81526004016104ca9061297b565b61061382600061156d565b6106286001600160a01b0383163330846118e8565b61053f82600161156d565b60009081526007602052604090205490565b6008546001600160a01b0316331461066f5760405162461bcd60e51b81526004016104ca90612ab6565b6001600160a01b0381166106955760405162461bcd60e51b81526004016104ca90612a1c565b600880546001600160a01b0319166001600160a01b0383169081179091556040517f71614071b88dee5e0b2ae578a9dd7b2ebbe9ae832ba419dc0242cd065a290b6c90600090a250565b6106e7611159565b6106f18382611942565b8260005b8181101561086e57600a600087878481811061070d57fe5b9050602002016020810190610722919061248e565b6001600160a01b0316815260208101919091526040016000205460ff1661075b5760405162461bcd60e51b81526004016104ca906128e3565b61078686868381811061076a57fe5b905060200201602081019061077f919061248e565b600061156d565b6107d0333086868581811061079757fe5b905060200201358989868181106107aa57fe5b90506020020160208101906107bf919061248e565b6001600160a01b03169291906118e8565b6107fb8686838181106107df57fe5b90506020020160208101906107f4919061248e565b600161156d565b7f95bf5847357310d24f8d03d8bad76c8ee329dfd3a3cb200df21c7bd1619e93bd86868381811061082857fe5b905060200201602081019061083d919061248e565b85858481811061084957fe5b9050602002013560405161085e9291906127f5565b60405180910390a16001016106f5565b5050610878611566565b50505050565b600061088a838361194f565b90505b92915050565b60065490565b6001600160a01b03166000908152600d6020526040902054600160401b90046001600160401b031690565b60606108ce611159565b836108d8816119cc565b6108e0611a04565b6108e985611170565b826000816001600160401b038111801561090257600080fd5b5060405190808252806020026020018201604052801561092c578160200160208202803683370190505b50905060005b82811015610a7357600a600088888481811061094a57fe5b905060200201602081019061095f919061248e565b6001600160a01b0316815260208101919091526040016000205460ff166109985760405162461bcd60e51b81526004016104ca906128e3565b6109a787878381811061076a57fe5b6109d1888888848181106109b757fe5b90506020020160208101906109cc919061248e565b611b58565b8282815181106109dd57fe5b60209081029190910101526004546001600160a01b031663c7b56abe888884818110610a0557fe5b9050602002016020810190610a1a919061248e565b6040518263ffffffff1660e01b8152600401610a3691906127b8565b600060405180830381600087803b158015610a5057600080fd5b505af1158015610a64573d6000803e3d6000fd5b50505050806001019050610932565b5092505050610a80611566565b9392505050565b6001600160a01b031660009081526020819052604090205490565b610aaa611159565b8060005b81811015610b2b57600a6000858584818110610ac657fe5b9050602002016020810190610adb919061248e565b6001600160a01b0316815260208101919091526040016000205460ff16610b145760405162461bcd60e51b81526004016104ca906128e3565b610b238484838181106107df57fe5b600101610aae565b505061053f611566565b6001600160a01b031660009081526001602052604090205460ff1690565b6001600160a01b03166000908152600b6020526040902054600160401b90046001600160401b031690565b60035460ff1615610ba15760405162461bcd60e51b81526004016104ca9061293c565b6003805460ff191660011790556001600160a01b03811615801590610bce57506001600160a01b03831615155b610bea5760405162461bcd60e51b81526004016104ca9061295c565b600880546001600160a01b038084166001600160a01b0319928316179092556004805486841692169190911790556003805491861661010002610100600160a81b0319909216919091179055610c3f82611d2a565b91506000610c4c42611d2a565b905080831015610c6e5760405162461bcd60e51b81526004016104ca906129e7565b80625c490001831115610c935760405162461bcd60e51b81526004016104ca90612a42565b80831415610d375760405163bd85b03960e01b81526000906001600160a01b0387169063bd85b03990610cca908590600401612892565b60206040518083038186803b158015610ce257600080fd5b505afa158015610cf6573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610d1a91906127a0565b11610d375760405162461bcd60e51b81526004016104ca90612a6e565b505060058190556006555050565b610d4d611159565b610d55611a04565b610d5d611566565b565b6000610d69611159565b82610d73816119cc565b6001600160a01b0383166000908152600a602052604090205460ff16610dab5760405162461bcd60e51b81526004016104ca906128e3565b610db3611a04565b610dbc84611170565b610dc783600061156d565b6000610dd38585611b58565b600480546040516363dab55f60e11b81529293506001600160a01b03169163c7b56abe91610e03918891016127b8565b600060405180830381600087803b158015610e1d57600080fd5b505af1158015610e31573d6000803e3d6000fd5b509294505050505061088d611566565b6001600160a01b03919091166000908152600c60209081526040808320938352929052205490565b6001600160a01b03919091166000908152600e60209081526040808320938352929052205490565b6104583382611d36565b60606009805480602002602001604051908101604052809291908181526020018280548015610ef357602002820191906000526020600020905b81546001600160a01b03168152600190910190602001808311610ed5575b5050505050905090565b6000610f07611d9a565b905090565b600a6020526000908152604090205460ff1681565b6008546001600160a01b03163314610f4b5760405162461bcd60e51b81526004016104ca90612ab6565b60005b818110156110b757600a6000848484818110610f6657fe5b9050602002016020810190610f7b919061248e565b6001600160a01b0316815260208101919091526040016000205460ff1615610fb55760405162461bcd60e51b81526004016104ca906128bc565b6001600a6000858585818110610fc757fe5b9050602002016020810190610fdc919061248e565b6001600160a01b031681526020810191909152604001600020805460ff1916911515919091179055600983838381811061101257fe5b9050602002016020810190611027919061248e565b81546001810183556000928352602090922090910180546001600160a01b0319166001600160a01b0390921691909117905582828281811061106557fe5b905060200201602081019061107a919061248e565b6001600160a01b03167f784c8f4dbf0ffedd6e72c76501c545a70f8b203b30a26ce542bf92ba87c248a460405160405180910390a2600101610f4e565b505050565b6008546001600160a01b031681565b60007fbd291ffccec065968fe20c5f8debdad73ab50837733f357eeae8814178015a9084846110f987610a87565b60405160200180858152602001846001600160a01b03168152602001831515815260200182815260200194505050505060405160208183030381529060405280519060200120905061114f8482846101f8611e58565b6108788484611d36565b61116a600280541415610190611e67565b60028055565b60035460405163010ae75760e01b815260009161010090046001600160a01b03169063010ae757906111a69085906004016127b8565b60206040518083038186803b1580156111be57600080fd5b505afa1580156111d2573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906111f691906127a0565b9050806112035750610458565b6001600160a01b0382166000908152600d6020526040812080549091600160401b9091046001600160401b0316908161124c5761124585600554600087611e75565b9050611289565b42821061125c5750505050610458565b508154600160801b90046001600160801b0316601481850311156112895761128685838387611e75565b90505b80611292575060015b6003546040516328d09d4760e01b815260009161010090046001600160a01b0316906328d09d47906112ca90899086906004016127f5565b60806040518083038186803b1580156112e257600080fd5b505afa1580156112f6573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061131a919061271e565b9050826113775760055442116113425760405162461bcd60e51b81526004016104ca906129a0565b61135a6005546113558360400151611f54565b611f64565b845467ffffffffffffffff19166001600160401b03821617855592505b61137f6123f6565b60005b6032811015611519578260400151851015801561139f5750868411155b1561147557600184019350829150868411156113e75760405180608001604052806000600f0b81526020016000600f0b81526020016000815260200160008152509250611470565b6003546040516328d09d4760e01b81526101009091046001600160a01b0316906328d09d479061141d908b9088906004016127f5565b60806040518083038186803b15801561143557600080fd5b505afa158015611449573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061146d919061271e565b92505b611511565b42851061148157611519565b6000826040015186039050600081846020015102600f0b8460000151600f0b136114ac5760006114bd565b81846020015102846000015103600f0b5b9050801580156114cc57508886115b156114e3576114da42611f54565b96505050611519565b6001600160a01b038a166000908152600e602090815260408083208a84529091529020555062093a80909401935b600101611382565b505083546001600160801b0316600019929092016001600160401b03908116600160801b029290921767ffffffffffffffff60401b1916600160401b939092169290920217909155505050565b6001600255565b6001600160a01b0382166000908152600b6020526040812080549091600160401b9091046001600160401b031690816115ee574291506115ac42611d2a565b835467ffffffffffffffff19166001600160401b039190911617835560055442116115e95760405162461bcd60e51b81526004016104ca906129a0565b611640565b81420390508361164057600061160383611d2a565b61160c42611d2a565b1490506000620151804261161f42611f54565b0310905081801561162e575080155b1561163d57505050505061053f565b50505b825467ffffffffffffffff60401b1916600160401b426001600160401b0316021783556040516370a0823160e01b81526000906001600160a01b038716906370a08231906116929030906004016127b8565b60206040518083038186803b1580156116aa57600080fd5b505afa1580156116be573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906116e291906127a0565b8454909150600090611705908390600160801b90046001600160801b0316611f70565b90508061171657505050505061053f565b6001600160801b0382111561173d5760405162461bcd60e51b81526004016104ca90612905565b600061174885611d2a565b6001600160a01b0389166000908152600c602052604081209192509081805b601481101561189f578462093a800193508342101561180a578715801561178d57508842145b1561179a578591506117ab565b878942038702816117a757fe5b0491505b6000858152602084905260409020546001600160801b03906117cd9084611f7e565b1161180557600085815260208490526040902080548301905589546001600160801b03600160801b808304821685018216029116178a555b61189f565b8715801561181757508884145b1561182457859150611835565b8789850387028161183157fe5b0491505b6000858152602084905260409020546001600160801b03906118579084611f7e565b1161188f57600085815260208490526040902080548301905589546001600160801b03600160801b808304821685018216029116178a555b9297508793508392600101611767565b507f9b7f1a85a4c9b4e59e1b6527d9969c50cdfb3a1a467d0c4a51fb0ed8bf07f1308b868a6040516118d39392919061289b565b60405180910390a15050505050505050505050565b604080516001600160a01b0380861660248301528416604482015260648082018490528251808303909101815260849091019091526020810180516001600160e01b03166323b872dd60e01b179052610878908590611f90565b61053f8183146067611e67565b6001600160a01b038083166000908152600f60209081526040808320938516835292905290812054801561198457905061088d565b6001600160a01b038085166000908152600d60209081526040808320549387168352600b9091529020546119c4916001600160401b039081169116611f64565b949350505050565b6001600160a01b03811660009081526001602052604090205460ff161561045857610458336001600160a01b03831614610191611e67565b6006546000611a1242611d2a565b905080821180611a2157504281145b15611a2d575050610d5d565b600360019054906101000a90046001600160a01b03166001600160a01b031663c2c4c5c16040518163ffffffff1660e01b8152600401600060405180830381600087803b158015611a7d57600080fd5b505af1158015611a91573d6000803e3d6000fd5b5050505060005b6014811015611b515781831115611aae57611b51565b60035460405163bd85b03960e01b81526101009091046001600160a01b03169063bd85b03990611ae2908690600401612892565b60206040518083038186803b158015611afa57600080fd5b505afa158015611b0e573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611b3291906127a0565b60008481526007602052604090205562093a8090920191600101611a98565b5050600655565b6001600160a01b0381166000908152600b6020526040812081611b7b858561194f565b6006546001600160a01b0387166000908152600d602052604081205492935091611be291611bc291611bbd9190600160401b90046001600160401b031661207a565b611f54565b8454611bdd90600160401b90046001600160401b0316611d2a565b61207a565b6001600160a01b038087166000908152600c60209081526040808320938b168352600e9091528120929350909190805b6014811015611c7e57848610611c2757611c7e565b600086815260076020526040902054611c3f57611c7e565b60008681526007602090815260408083205486835281842054928890529220540281611c6757fe5b62093a809790970196049190910190600101611c12565b506001600160a01b03808a166000908152600f60209081526040808320938c168352929052208590558015611d1e5785546001600160801b03600160801b80830482168490038216029116178655611ce06001600160a01b0389168a83612086565b7fff097c7d8b1957a4ff09ef1361b5fb54dcede3941ba836d0beb9d10bec725de689898388604051611d1594939291906127cc565b60405180910390a15b98975050505050505050565b62093a80908190040290565b6001600160a01b038216600081815260016020908152604091829020805460ff191685151590811790915582519384529083015280517fac9874a7a931a3f5c9f202c6d9cf40de5d21506993c9f9c38ca8265add89584c9281900390910190a15050565b60007f00000000000000000000000000000000000000000000000000000000000000007f00000000000000000000000000000000000000000000000000000000000000007f0000000000000000000000000000000000000000000000000000000000000000611e076120d8565b3060405160200180868152602001858152602001848152602001838152602001826001600160a01b031681526020019550505050505060405160208183030381529060405280519060200120905090565b610878848484600019856120dc565b8161053f5761053f81612133565b60008282825b6080811015611f4857818310611e9057611f48565b6003546040516328d09d4760e01b81526002858501810104916000916101009091046001600160a01b0316906328d09d4790611ed2908d9086906004016127f5565b60806040518083038186803b158015611eea57600080fd5b505afa158015611efe573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611f22919061271e565b905088816040015111611f3757819450611f3e565b6001820393505b5050600101611e7b565b50909695505050505050565b600061088d62093a7f8301611d2a565b80820390821002900390565b600061088a83836001612143565b600082820161088a8482101583611e67565b600080836001600160a01b0316836040518082805190602001908083835b60208310611fcd5780518252601f199092019160209182019101611fae565b6001836020036101000a0380198251168184511680821785525050505050509050019150506000604051808303816000865af19150503d806000811461202f576040519150601f19603f3d011682016040523d82523d6000602084013e612034565b606091505b5091509150600082141561204c573d6000803e3d6000fd5b610878815160001480612072575081806020019051602081101561206f57600080fd5b50515b6101a2611e67565b80820390821102900390565b604080516001600160a01b038416602482015260448082018490528251808303909101815260649091019091526020810180516001600160e01b031663a9059cbb60e01b1790526110b7908490611f90565b4690565b60006120e785612159565b90506120fd6120f78783876121a5565b83611e67565b61210c428410156101b8611e67565b5050506001600160a01b039092166000908152602081905260409020805460010190555050565b610458816210905360ea1b6122c3565b60006121528484111583611e67565b5050900390565b6000612163611d9a565b82604051602001808061190160f01b81525060020183815260200182815260200192505050604051602081830303815290604052805190602001209050919050565b60006121b9846001600160a01b0316612324565b156122b15760408051630b135d3f60e11b808252600482018681526024830193845285516044840152855191936001600160a01b03891693631626ba7e938993899390929091606490910190602085019080838360005b83811015612228578181015183820152602001612210565b50505050905090810190601f1680156122555780820380516001836020036101000a031916815260200191505b50935050505060206040518083038186803b15801561227357600080fd5b505afa158015612287573d6000803e3d6000fd5b505050506040513d602081101561229d57600080fd5b50516001600160e01b031916149050610a80565b6122bc84848461232a565b9050610a80565b62461bcd60e51b600090815260206004526007602452600a808404818106603090810160081b958390069590950190829004918206850160101b01602363ffffff0060e086901c160160181b0190930160c81b60445260e882901c90606490fd5b3b151590565b600061233c82516041146101b9611e67565b60008060006020850151925060408501519150606085015160001a9050600060018783868660405160008152602001604052604051808581526020018460ff1681526020018381526020018281526020019450505050506020604051602081039080840390855afa1580156123b5573d6000803e3d6000fd5b5050604051601f1901519150506001600160a01b03811615801590611d1e5750876001600160a01b0316816001600160a01b03161498975050505050505050565b60405180608001604052806000600f0b81526020016000600f0b815260200160008152602001600081525090565b60008083601f840112612435578081fd5b5081356001600160401b0381111561244b578182fd5b602083019150836020808302850101111561246557600080fd5b9250929050565b8035801515811461048557600080fd5b8051600f81900b811461048557600080fd5b60006020828403121561249f578081fd5b813561088a81612afc565b6000806000604084860312156124be578182fd5b83356124c981612afc565b925060208401356001600160401b038111156124e3578283fd5b6124ef86828701612424565b9497909650939450505050565b600080600060608486031215612510578283fd5b833561251b81612afc565b9250602061252a85820161246c565b925060408501356001600160401b0380821115612545578384fd5b818701915087601f830112612558578384fd5b81358181111561256457fe5b612576601f8201601f19168501612ad9565b9150808252888482850101111561258b578485fd5b808484018584013784848284010152508093505050509250925092565b600080604083850312156125ba578182fd5b82356125c581612afc565b915060208301356125d581612afc565b809150509250929050565b600080604083850312156125f2578182fd5b82356125fd81612afc565b946020939093013593505050565b6000806020838503121561261d578182fd5b82356001600160401b03811115612632578283fd5b61263e85828601612424565b90969095509350505050565b6000806000806040858703121561265f578081fd5b84356001600160401b0380821115612675578283fd5b61268188838901612424565b90965094506020870135915080821115612699578283fd5b506126a687828801612424565b95989497509550505050565b6000602082840312156126c3578081fd5b61088a8261246c565b600080600080608085870312156126e1578182fd5b84356126ec81612afc565b935060208501356126fc81612afc565b925060408501359150606085013561271381612afc565b939692955090935050565b60006080828403121561272f578081fd5b604051608081018181106001600160401b038211171561274b57fe5b6040526127578361247c565b81526127656020840161247c565b602082015260408301516040820152606083015160608201528091505092915050565b600060208284031215612799578081fd5b5035919050565b6000602082840312156127b1578081fd5b5051919050565b6001600160a01b0391909116815260200190565b6001600160a01b0394851681529290931660208301526040820152606081019190915260800190565b6001600160a01b03929092168252602082015260400190565b6020808252825182820181905260009190848201906040850190845b81811015611f485783516001600160a01b03168352928401929184019160010161282a565b6020808252825182820181905260009190848201906040850190845b81811015611f485783518352928401929184019160010161286b565b901515815260200190565b90815260200190565b6001600160a01b039390931683526020830191909152604082015260600190565b6020808252600d908201526c185b1c9958591e48195e1a5cdd609a1b604082015260600190565b60208082526008908201526708585b1b1bddd95960c21b604082015260600190565b6020808252601e908201527f4d6178696d756d20746f6b656e2062616c616e63652065786365656465640000604082015260600190565b60208082526006908201526521747769636560d01b604082015260600190565b602080825260059082015264217a65726f60d81b604082015260600190565b6020808252600b908201526a1bdb9b1e4819985d58d95d60aa1b604082015260600190565b60208082526027908201527f52657761726420646973747269627574696f6e20686173206e6f7420737461726040820152661d1959081e595d60ca1b606082015260800190565b6020808252818101527f43616e6e6f74207374617274206265666f72652063757272656e74207765656b604082015260600190565b6020808252600c908201526b7a65726f206164647265737360a01b604082015260600190565b6020808252601290820152710626040eecacad6e640c8cad8c2f240dac2f60731b604082015260600190565b60208082526028908201527f5a65726f20746f74616c20737570706c7920726573756c747320696e206c6f736040820152677420746f6b656e7360c01b606082015260800190565b6020808252600990820152683737ba1030b236b4b760b91b604082015260600190565b6040518181016001600160401b0381118282101715612af457fe5b604052919050565b6001600160a01b038116811461045857600080fdfea2646970667358221220972c2bb8ecdbe63efa080ae50f636a301051bc328845b00b90454df26829a68764736f6c63430007060033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/abis/RewardFaucet.json b/apps/api/src/app/contracts/abis/RewardFaucet.json new file mode 100644 index 000000000..26d7680f1 --- /dev/null +++ b/apps/api/src/app/contracts/abis/RewardFaucet.json @@ -0,0 +1,324 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "RewardFaucet", + "sourceName": "contracts/RewardFaucet.sol", + "abi": [ + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "weekStart", + "type": "uint256" + } + ], + "name": "DistributePast", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "totalAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "weeksCount", + "type": "uint256" + } + ], + "name": "ExactWeekDistribution", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "moveAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "pastWeekStart", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "nextWeekStart", + "type": "uint256" + } + ], + "name": "MovePastRewards", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "totalAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "weeksCount", + "type": "uint256" + } + ], + "name": "WeeksDistributions", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "weeksCount", + "type": "uint256" + } + ], + "name": "depositEqualWeeksPeriod", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "weekTimeStamp", + "type": "uint256" + } + ], + "name": "depositExactWeek", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "distributePastRewards", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "pointOfWeek", + "type": "uint256" + } + ], + "name": "getTokenWeekAmounts", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "weeksCount", + "type": "uint256" + } + ], + "name": "getUpcomingRewardsForNWeeks", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_rewardDistributor", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "isInitialized", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "pastWeekTimestamp", + "type": "uint256" + } + ], + "name": "movePastRewards", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "rewardDistributor", + "outputs": [ + { + "internalType": "contract IRewardDistributor", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "weekStart", + "type": "uint256" + } + ], + "name": "tokenWeekAmounts", + "outputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "totalTokenRewards", + "outputs": [ + { + "internalType": "uint256", + "name": "rewardAmount", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } + ], + "bytecode": "0x608060405234801561001057600080fd5b506001600055611315806100256000396000f3fe608060405234801561001057600080fd5b50600436106100a95760003560e01c806367b870111161007157806367b8701114610139578063871451781461014c578063acc2166a14610177578063c4d66de8146101a7578063c7b56abe146101ba578063cdcea6ad146101cd57600080fd5b80632a419597146100ae5780632d9cd3bb146100c357806336bcebb1146100d6578063392e53cd146101095780635530d7bd14610126575b600080fd5b6100c16100bc3660046110b4565b6101ed565b005b6100c16100d13660046110b4565b6104f0565b6100f66100e43660046110e7565b60026020526000908152604090205481565b6040519081526020015b60405180910390f35b6001546101169060ff1681565b6040519015158152602001610100565b6100c1610134366004611109565b61076c565b6100f6610147366004611109565b610876565b6100f661015a366004611109565b600360209081526000928352604080842090915290825290205481565b60015461018f9061010090046001600160a01b031681565b6040516001600160a01b039091168152602001610100565b6100c16101b53660046110e7565b6108af565b6100c16101c83660046110e7565b610952565b6101e06101db366004611109565b610b28565b6040516101009190611133565b6101f5610c00565b600081118015610206575060688111155b61023f5760405162461bcd60e51b8152602060048201526005602482015264217765656b60d81b60448201526064015b60405180910390fd5b6001600160a01b0383166000818152600260205260408082205490516370a0823160e01b8152306004820152919290916370a0823190602401602060405180830381865afa158015610295573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102b99190611177565b6102c391906111a6565b905060008082116102d457836102de565b6102de82856111b9565b905060006102ec84836111cc565b9050836001146103fa57600061030142610c59565b905060025b8581116103c0578581036103715761031f6001876111a6565b61032990846111ee565b61033390856111a6565b6001600160a01b0389166000908152600360209081526040808320868452909152812080549091906103669084906111b9565b909155506103c09050565b6001600160a01b0388166000908152600360209081526040808320858452909152812080548592906103a49084906111b9565b90915550506001016103b962093a80836111b9565b9150610306565b506103cb82846111a6565b6001600160a01b038816600090815260026020526040812080549091906103f39084906111b9565b9091555050505b61040f6001600160a01b038716333088610c70565b6001546001600160a01b036101009091048116906104309088168284610ce1565b60405163099348fb60e31b81526001600160a01b03888116600483015260248201849052821690634c9a47d890604401600060405180830381600087803b15801561047a57600080fd5b505af115801561048e573d6000803e3d6000fd5b5050604080516001600160a01b038b168152602081018790529081018890527f4088fafdc9c718bca399ea616e6c39e860759e8cc97fbda29803d42b0bc6a2249250606001905060405180910390a1505050506104eb6001600055565b505050565b6104f8610c00565b61050142610d70565b811015801561051d5750610519426303bfc4006111b9565b8111155b6105545760405162461bcd60e51b8152602060048201526008602482015267626164207765656b60c01b6044820152606401610236565b6001600160a01b0383166000818152600260205260408082205490516370a0823160e01b8152306004820152919290916370a0823190602401602060405180830381865afa1580156105aa573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906105ce9190611177565b6105d891906111a6565b905060008082116105e957836105f3565b6105f382856111b9565b905061060a6001600160a01b038616333087610c70565b600061061584610d70565b905061062042610d70565b81036106af576001546001600160a01b036101009091048116906106479088168285610ce1565b60405163099348fb60e31b81526001600160a01b03888116600483015260248201859052821690634c9a47d890604401600060405180830381600087803b15801561069157600080fd5b505af11580156106a5573d6000803e3d6000fd5b5050505050610715565b6001600160a01b0386166000908152600360209081526040808320848452909152812080548492906106e29084906111b9565b90915550506001600160a01b0386166000908152600260205260408120805484929061070f9084906111b9565b90915550505b604080516001600160a01b0388168152602081018490529081018290527fb86a33c5016189ecd35a2699118bc2fe7c716f81d4e837eef84dee0bb67875f59060600160405180910390a15050506104eb6001600055565b600061077782610d70565b905062530e8061078642610d70565b61079091906111a6565b81106107c95760405162461bcd60e51b8152602060048201526008602482015267216f75746461746560c01b6044820152606401610236565b60006107d442610c59565b6001600160a01b0385166000908152600360209081526040808320868452909152808220805490839055838352908220805493945090928392906108199084906111b9565b9091555050604080516001600160a01b038716815260208101839052908101849052606081018390527f6e9630aa131b46b81742f09d3aea83da20d184c4dbe662050e8d6d2d554503929060800160405180910390a15050505050565b60008061088283610d70565b6001600160a01b038516600090815260036020908152604080832093835292905220549150505b92915050565b60015460ff16156108eb5760405162461bcd60e51b815260206004820152600660248201526521747769636560d01b6044820152606401610236565b6001600160a01b0381166109295760405162461bcd60e51b8152602060048201526005602482015264217a65726f60d81b6044820152606401610236565b600180546001600160a01b03909216610100026001600160a81b03199092169190911781179055565b6001600160a01b03811660009081526002602052604081205490036109745750565b600061097f42610d70565b90506000805b600a811015610a20576001600160a01b0384166000908152600360209081526040808320868452909152812054908190036109cf576109c762093a80856111a6565b935050610a10565b6001600160a01b03851660009081526003602090815260408083208784529091528120556109fd81846111b9565b9250610a0c62093a80856111a6565b9350505b610a1981611205565b9050610985565b5080156104eb576001600160a01b03831660009081526002602052604081208054839290610a4f9084906111a6565b90915550506001546001600160a01b03610100909104811690610a759085168284610ce1565b60405163099348fb60e31b81526001600160a01b03858116600483015260248201849052821690634c9a47d890604401600060405180830381600087803b158015610abf57600080fd5b505af1158015610ad3573d6000803e3d6000fd5b5050604080516001600160a01b0388168152602081018690529081018690527f33d07963ee4e58f177134d0a37785787f0056ee388c04e4aff075e61e2856d6c9250606001905060405180910390a150505050565b60606000610b3542610d70565b905060008367ffffffffffffffff811115610b5257610b5261121e565b604051908082528060200260200182016040528015610b7b578160200160208202803683370190505b50905060005b84811015610bf7576001600160a01b038616600090815260036020526040812090610baf8362093a806111ee565b610bb990866111b9565b815260200190815260200160002054828281518110610bda57610bda611234565b602090810291909101015280610bef81611205565b915050610b81565b50949350505050565b600260005403610c525760405162461bcd60e51b815260206004820152601f60248201527f5265656e7472616e637947756172643a207265656e7472616e742063616c6c006044820152606401610236565b6002600055565b60006108a9610c6b8362093a806111b9565b610d70565b6040516001600160a01b0380851660248301528316604482015260648101829052610cdb9085906323b872dd60e01b906084015b60408051601f198184030181529190526020810180516001600160e01b03166001600160e01b031990931692909217909152610d8c565b50505050565b604080516001600160a01b038416602482015260448082018490528251808303909101815260649091019091526020810180516001600160e01b031663095ea7b360e01b179052610d328482610e61565b610cdb576040516001600160a01b038416602482015260006044820152610d6690859063095ea7b360e01b90606401610ca4565b610cdb8482610d8c565b6000610d7f62093a80836111cc565b6108a99062093a806111ee565b6000610de1826040518060400160405280602081526020017f5361666545524332303a206c6f772d6c6576656c2063616c6c206661696c6564815250856001600160a01b0316610f089092919063ffffffff16565b9050805160001480610e02575080806020019051810190610e02919061124a565b6104eb5760405162461bcd60e51b815260206004820152602a60248201527f5361666545524332303a204552433230206f7065726174696f6e20646964206e6044820152691bdd081cdd58d8d9595960b21b6064820152608401610236565b6000806000846001600160a01b031684604051610e7e9190611290565b6000604051808303816000865af19150503d8060008114610ebb576040519150601f19603f3d011682016040523d82523d6000602084013e610ec0565b606091505b5091509150818015610eea575080511580610eea575080806020019051810190610eea919061124a565b8015610eff57506001600160a01b0385163b15155b95945050505050565b6060610f178484600085610f1f565b949350505050565b606082471015610f805760405162461bcd60e51b815260206004820152602660248201527f416464726573733a20696e73756666696369656e742062616c616e636520666f6044820152651c8818d85b1b60d21b6064820152608401610236565b600080866001600160a01b03168587604051610f9c9190611290565b60006040518083038185875af1925050503d8060008114610fd9576040519150601f19603f3d011682016040523d82523d6000602084013e610fde565b606091505b5091509150610fef87838387610ffa565b979650505050505050565b60608315611069578251600003611062576001600160a01b0385163b6110625760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610236565b5081610f17565b610f17838381511561107e5781518083602001fd5b8060405162461bcd60e51b815260040161023691906112ac565b80356001600160a01b03811681146110af57600080fd5b919050565b6000806000606084860312156110c957600080fd5b6110d284611098565b95602085013595506040909401359392505050565b6000602082840312156110f957600080fd5b61110282611098565b9392505050565b6000806040838503121561111c57600080fd5b61112583611098565b946020939093013593505050565b6020808252825182820181905260009190848201906040850190845b8181101561116b5783518352928401929184019160010161114f565b50909695505050505050565b60006020828403121561118957600080fd5b5051919050565b634e487b7160e01b600052601160045260246000fd5b818103818111156108a9576108a9611190565b808201808211156108a9576108a9611190565b6000826111e957634e487b7160e01b600052601260045260246000fd5b500490565b80820281158282048414176108a9576108a9611190565b60006001820161121757611217611190565b5060010190565b634e487b7160e01b600052604160045260246000fd5b634e487b7160e01b600052603260045260246000fd5b60006020828403121561125c57600080fd5b8151801515811461110257600080fd5b60005b8381101561128757818101518382015260200161126f565b50506000910152565b600082516112a281846020870161126c565b9190910192915050565b60208152600082518060208401526112cb81604085016020870161126c565b601f01601f1916919091016040019291505056fea264697066735822122000f918e3a59af92e2a14d9123b3fe1755ca0de77c365c4e3d6bd71bea319dedd64736f6c63430008120033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100a95760003560e01c806367b870111161007157806367b8701114610139578063871451781461014c578063acc2166a14610177578063c4d66de8146101a7578063c7b56abe146101ba578063cdcea6ad146101cd57600080fd5b80632a419597146100ae5780632d9cd3bb146100c357806336bcebb1146100d6578063392e53cd146101095780635530d7bd14610126575b600080fd5b6100c16100bc3660046110b4565b6101ed565b005b6100c16100d13660046110b4565b6104f0565b6100f66100e43660046110e7565b60026020526000908152604090205481565b6040519081526020015b60405180910390f35b6001546101169060ff1681565b6040519015158152602001610100565b6100c1610134366004611109565b61076c565b6100f6610147366004611109565b610876565b6100f661015a366004611109565b600360209081526000928352604080842090915290825290205481565b60015461018f9061010090046001600160a01b031681565b6040516001600160a01b039091168152602001610100565b6100c16101b53660046110e7565b6108af565b6100c16101c83660046110e7565b610952565b6101e06101db366004611109565b610b28565b6040516101009190611133565b6101f5610c00565b600081118015610206575060688111155b61023f5760405162461bcd60e51b8152602060048201526005602482015264217765656b60d81b60448201526064015b60405180910390fd5b6001600160a01b0383166000818152600260205260408082205490516370a0823160e01b8152306004820152919290916370a0823190602401602060405180830381865afa158015610295573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102b99190611177565b6102c391906111a6565b905060008082116102d457836102de565b6102de82856111b9565b905060006102ec84836111cc565b9050836001146103fa57600061030142610c59565b905060025b8581116103c0578581036103715761031f6001876111a6565b61032990846111ee565b61033390856111a6565b6001600160a01b0389166000908152600360209081526040808320868452909152812080549091906103669084906111b9565b909155506103c09050565b6001600160a01b0388166000908152600360209081526040808320858452909152812080548592906103a49084906111b9565b90915550506001016103b962093a80836111b9565b9150610306565b506103cb82846111a6565b6001600160a01b038816600090815260026020526040812080549091906103f39084906111b9565b9091555050505b61040f6001600160a01b038716333088610c70565b6001546001600160a01b036101009091048116906104309088168284610ce1565b60405163099348fb60e31b81526001600160a01b03888116600483015260248201849052821690634c9a47d890604401600060405180830381600087803b15801561047a57600080fd5b505af115801561048e573d6000803e3d6000fd5b5050604080516001600160a01b038b168152602081018790529081018890527f4088fafdc9c718bca399ea616e6c39e860759e8cc97fbda29803d42b0bc6a2249250606001905060405180910390a1505050506104eb6001600055565b505050565b6104f8610c00565b61050142610d70565b811015801561051d5750610519426303bfc4006111b9565b8111155b6105545760405162461bcd60e51b8152602060048201526008602482015267626164207765656b60c01b6044820152606401610236565b6001600160a01b0383166000818152600260205260408082205490516370a0823160e01b8152306004820152919290916370a0823190602401602060405180830381865afa1580156105aa573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906105ce9190611177565b6105d891906111a6565b905060008082116105e957836105f3565b6105f382856111b9565b905061060a6001600160a01b038616333087610c70565b600061061584610d70565b905061062042610d70565b81036106af576001546001600160a01b036101009091048116906106479088168285610ce1565b60405163099348fb60e31b81526001600160a01b03888116600483015260248201859052821690634c9a47d890604401600060405180830381600087803b15801561069157600080fd5b505af11580156106a5573d6000803e3d6000fd5b5050505050610715565b6001600160a01b0386166000908152600360209081526040808320848452909152812080548492906106e29084906111b9565b90915550506001600160a01b0386166000908152600260205260408120805484929061070f9084906111b9565b90915550505b604080516001600160a01b0388168152602081018490529081018290527fb86a33c5016189ecd35a2699118bc2fe7c716f81d4e837eef84dee0bb67875f59060600160405180910390a15050506104eb6001600055565b600061077782610d70565b905062530e8061078642610d70565b61079091906111a6565b81106107c95760405162461bcd60e51b8152602060048201526008602482015267216f75746461746560c01b6044820152606401610236565b60006107d442610c59565b6001600160a01b0385166000908152600360209081526040808320868452909152808220805490839055838352908220805493945090928392906108199084906111b9565b9091555050604080516001600160a01b038716815260208101839052908101849052606081018390527f6e9630aa131b46b81742f09d3aea83da20d184c4dbe662050e8d6d2d554503929060800160405180910390a15050505050565b60008061088283610d70565b6001600160a01b038516600090815260036020908152604080832093835292905220549150505b92915050565b60015460ff16156108eb5760405162461bcd60e51b815260206004820152600660248201526521747769636560d01b6044820152606401610236565b6001600160a01b0381166109295760405162461bcd60e51b8152602060048201526005602482015264217a65726f60d81b6044820152606401610236565b600180546001600160a01b03909216610100026001600160a81b03199092169190911781179055565b6001600160a01b03811660009081526002602052604081205490036109745750565b600061097f42610d70565b90506000805b600a811015610a20576001600160a01b0384166000908152600360209081526040808320868452909152812054908190036109cf576109c762093a80856111a6565b935050610a10565b6001600160a01b03851660009081526003602090815260408083208784529091528120556109fd81846111b9565b9250610a0c62093a80856111a6565b9350505b610a1981611205565b9050610985565b5080156104eb576001600160a01b03831660009081526002602052604081208054839290610a4f9084906111a6565b90915550506001546001600160a01b03610100909104811690610a759085168284610ce1565b60405163099348fb60e31b81526001600160a01b03858116600483015260248201849052821690634c9a47d890604401600060405180830381600087803b158015610abf57600080fd5b505af1158015610ad3573d6000803e3d6000fd5b5050604080516001600160a01b0388168152602081018690529081018690527f33d07963ee4e58f177134d0a37785787f0056ee388c04e4aff075e61e2856d6c9250606001905060405180910390a150505050565b60606000610b3542610d70565b905060008367ffffffffffffffff811115610b5257610b5261121e565b604051908082528060200260200182016040528015610b7b578160200160208202803683370190505b50905060005b84811015610bf7576001600160a01b038616600090815260036020526040812090610baf8362093a806111ee565b610bb990866111b9565b815260200190815260200160002054828281518110610bda57610bda611234565b602090810291909101015280610bef81611205565b915050610b81565b50949350505050565b600260005403610c525760405162461bcd60e51b815260206004820152601f60248201527f5265656e7472616e637947756172643a207265656e7472616e742063616c6c006044820152606401610236565b6002600055565b60006108a9610c6b8362093a806111b9565b610d70565b6040516001600160a01b0380851660248301528316604482015260648101829052610cdb9085906323b872dd60e01b906084015b60408051601f198184030181529190526020810180516001600160e01b03166001600160e01b031990931692909217909152610d8c565b50505050565b604080516001600160a01b038416602482015260448082018490528251808303909101815260649091019091526020810180516001600160e01b031663095ea7b360e01b179052610d328482610e61565b610cdb576040516001600160a01b038416602482015260006044820152610d6690859063095ea7b360e01b90606401610ca4565b610cdb8482610d8c565b6000610d7f62093a80836111cc565b6108a99062093a806111ee565b6000610de1826040518060400160405280602081526020017f5361666545524332303a206c6f772d6c6576656c2063616c6c206661696c6564815250856001600160a01b0316610f089092919063ffffffff16565b9050805160001480610e02575080806020019051810190610e02919061124a565b6104eb5760405162461bcd60e51b815260206004820152602a60248201527f5361666545524332303a204552433230206f7065726174696f6e20646964206e6044820152691bdd081cdd58d8d9595960b21b6064820152608401610236565b6000806000846001600160a01b031684604051610e7e9190611290565b6000604051808303816000865af19150503d8060008114610ebb576040519150601f19603f3d011682016040523d82523d6000602084013e610ec0565b606091505b5091509150818015610eea575080511580610eea575080806020019051810190610eea919061124a565b8015610eff57506001600160a01b0385163b15155b95945050505050565b6060610f178484600085610f1f565b949350505050565b606082471015610f805760405162461bcd60e51b815260206004820152602660248201527f416464726573733a20696e73756666696369656e742062616c616e636520666f6044820152651c8818d85b1b60d21b6064820152608401610236565b600080866001600160a01b03168587604051610f9c9190611290565b60006040518083038185875af1925050503d8060008114610fd9576040519150601f19603f3d011682016040523d82523d6000602084013e610fde565b606091505b5091509150610fef87838387610ffa565b979650505050505050565b60608315611069578251600003611062576001600160a01b0385163b6110625760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610236565b5081610f17565b610f17838381511561107e5781518083602001fd5b8060405162461bcd60e51b815260040161023691906112ac565b80356001600160a01b03811681146110af57600080fd5b919050565b6000806000606084860312156110c957600080fd5b6110d284611098565b95602085013595506040909401359392505050565b6000602082840312156110f957600080fd5b61110282611098565b9392505050565b6000806040838503121561111c57600080fd5b61112583611098565b946020939093013593505050565b6020808252825182820181905260009190848201906040850190845b8181101561116b5783518352928401929184019160010161114f565b50909695505050505050565b60006020828403121561118957600080fd5b5051919050565b634e487b7160e01b600052601160045260246000fd5b818103818111156108a9576108a9611190565b808201808211156108a9576108a9611190565b6000826111e957634e487b7160e01b600052601260045260246000fd5b500490565b80820281158282048414176108a9576108a9611190565b60006001820161121757611217611190565b5060010190565b634e487b7160e01b600052604160045260246000fd5b634e487b7160e01b600052603260045260246000fd5b60006020828403121561125c57600080fd5b8151801515811461110257600080fd5b60005b8381101561128757818101518382015260200161126f565b50506000910152565b600082516112a281846020870161126c565b9190910192915050565b60208152600082518060208401526112cb81604085016020870161126c565b601f01601f1916919091016040019291505056fea264697066735822122000f918e3a59af92e2a14d9123b3fe1755ca0de77c365c4e3d6bd71bea319dedd64736f6c63430008120033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/abis/SmartWalletWhitelist.json b/apps/api/src/app/contracts/abis/SmartWalletWhitelist.json new file mode 100644 index 000000000..f6508b8dd --- /dev/null +++ b/apps/api/src/app/contracts/abis/SmartWalletWhitelist.json @@ -0,0 +1,229 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "SmartWalletWhitelist", + "sourceName": "contracts/utils/SmartWalletWhitelist.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "_admin", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "ApproveWallet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "NewChecker", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "RevokeWallet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "name": "SetAllowAll", + "type": "event" + }, + { + "inputs": [], + "name": "admin", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_wallet", + "type": "address" + } + ], + "name": "approveWallet", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_wallets", + "type": "address[]" + } + ], + "name": "approveWalletList", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_wallet", + "type": "address" + } + ], + "name": "check", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "checker", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "isAllowAll", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_wallet", + "type": "address" + } + ], + "name": "revokeWallet", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_wallets", + "type": "address[]" + } + ], + "name": "revokeWalletList", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bool", + "name": "_isAllowAll", + "type": "bool" + } + ], + "name": "setAllowAll", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_checker", + "type": "address" + } + ], + "name": "setChecker", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "wallets", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + } + ], + "bytecode": "0x60a06040526001805460ff60a01b1916905534801561001d57600080fd5b506040516107ed3803806107ed83398101604081905261003c9161004d565b6001600160a01b031660805261007d565b60006020828403121561005f57600080fd5b81516001600160a01b038116811461007657600080fd5b9392505050565b6080516107326100bb600039600081816101b1015281816101d50152818161030101528181610396015281816103e7015261054901526107326000f3fe608060405234801561001057600080fd5b50600436106100a95760003560e01c8063808a9d4011610071578063808a9d401461012557806389b08f1114610138578063c23697a81461015b578063cf5303cf1461016e578063cf880f4c14610199578063f851a440146101ac57600080fd5b80630fcb0ae5146100ae5780631b833791146100c35780632c6766c6146100d6578063396ad9d3146100ff5780634d7d9c0114610112575b600080fd5b6100c16100bc3660046105dd565b6101d3565b005b6100c16100d1366004610606565b61027d565b6001546100ea90600160a01b900460ff1681565b60405190151581526020015b60405180910390f35b6100c161010d366004610606565b6102c1565b6100c161012036600461068c565b6102ff565b6100c16101333660046105dd565b610394565b6100ea6101463660046105dd565b60006020819052908152604090205460ff1681565b6100ea6101693660046105dd565b610473565b600154610181906001600160a01b031681565b6040516001600160a01b0390911681526020016100f6565b6100c16101a73660046105dd565b610547565b6101817f000000000000000000000000000000000000000000000000000000000000000081565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031633146102245760405162461bcd60e51b815260040161021b906106a9565b60405180910390fd5b6001600160a01b03811660008181526020818152604091829020805460ff1916600117905590519182527fc1e7aae3f3125e58cfc69ab2a872a655dbb9427614aa85b29bb5abeaca4d6a9291015b60405180910390a150565b8060005b818110156102bb576102b384848381811061029e5761029e6106c9565b905060200201602081019061013391906105dd565b600101610281565b50505050565b8060005b818110156102bb576102f78484838181106102e2576102e26106c9565b90506020020160208101906100bc91906105dd565b6001016102c5565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031633146103475760405162461bcd60e51b815260040161021b906106a9565b60018054821515600160a01b0260ff60a01b199091161790556040517fd6b910c3c867dfdca6ffe654abf208c60c6b831d36ddc04b3c5f43c2982caa379061027290831515815260200190565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031633146103dc5760405162461bcd60e51b815260040161021b906106a9565b336001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016146104245760405162461bcd60e51b815260040161021b906106a9565b6001600160a01b03811660008181526020818152604091829020805460ff1916905590519182527f1b676c3cc753786cb95aff57280fd7406f1da74e2a8b9755fdd395aded3e16dd9101610272565b600154600090600160a01b900460ff161561049057506001919050565b6001600160a01b03821660009081526020819052604090205460ff1680156104b85792915050565b6001546001600160a01b03161561053e57600154604051631846d2f560e31b81526001600160a01b0385811660048301529091169063c23697a890602401602060405180830381865afa158015610513573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061053791906106df565b9392505050565b50600092915050565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316331461058f5760405162461bcd60e51b815260040161021b906106a9565b600180546001600160a01b0319166001600160a01b0383169081179091556040519081527fbbb218c618aff8fc737012ba779aad87c1cf7a60b501bd2f72bb570517f02ca390602001610272565b6000602082840312156105ef57600080fd5b81356001600160a01b038116811461053757600080fd5b6000806020838503121561061957600080fd5b823567ffffffffffffffff8082111561063157600080fd5b818501915085601f83011261064557600080fd5b81358181111561065457600080fd5b8660208260051b850101111561066957600080fd5b60209290920196919550909350505050565b801515811461068957600080fd5b50565b60006020828403121561069e57600080fd5b81356105378161067b565b60208082526006908201526510b0b236b4b760d11b604082015260600190565b634e487b7160e01b600052603260045260246000fd5b6000602082840312156106f157600080fd5b81516105378161067b56fea26469706673582212205ce205a4575174bf41d715179b300702d85eae262bc2732b0cb6d1c8c6358bcc64736f6c63430008120033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100a95760003560e01c8063808a9d4011610071578063808a9d401461012557806389b08f1114610138578063c23697a81461015b578063cf5303cf1461016e578063cf880f4c14610199578063f851a440146101ac57600080fd5b80630fcb0ae5146100ae5780631b833791146100c35780632c6766c6146100d6578063396ad9d3146100ff5780634d7d9c0114610112575b600080fd5b6100c16100bc3660046105dd565b6101d3565b005b6100c16100d1366004610606565b61027d565b6001546100ea90600160a01b900460ff1681565b60405190151581526020015b60405180910390f35b6100c161010d366004610606565b6102c1565b6100c161012036600461068c565b6102ff565b6100c16101333660046105dd565b610394565b6100ea6101463660046105dd565b60006020819052908152604090205460ff1681565b6100ea6101693660046105dd565b610473565b600154610181906001600160a01b031681565b6040516001600160a01b0390911681526020016100f6565b6100c16101a73660046105dd565b610547565b6101817f000000000000000000000000000000000000000000000000000000000000000081565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031633146102245760405162461bcd60e51b815260040161021b906106a9565b60405180910390fd5b6001600160a01b03811660008181526020818152604091829020805460ff1916600117905590519182527fc1e7aae3f3125e58cfc69ab2a872a655dbb9427614aa85b29bb5abeaca4d6a9291015b60405180910390a150565b8060005b818110156102bb576102b384848381811061029e5761029e6106c9565b905060200201602081019061013391906105dd565b600101610281565b50505050565b8060005b818110156102bb576102f78484838181106102e2576102e26106c9565b90506020020160208101906100bc91906105dd565b6001016102c5565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031633146103475760405162461bcd60e51b815260040161021b906106a9565b60018054821515600160a01b0260ff60a01b199091161790556040517fd6b910c3c867dfdca6ffe654abf208c60c6b831d36ddc04b3c5f43c2982caa379061027290831515815260200190565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031633146103dc5760405162461bcd60e51b815260040161021b906106a9565b336001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016146104245760405162461bcd60e51b815260040161021b906106a9565b6001600160a01b03811660008181526020818152604091829020805460ff1916905590519182527f1b676c3cc753786cb95aff57280fd7406f1da74e2a8b9755fdd395aded3e16dd9101610272565b600154600090600160a01b900460ff161561049057506001919050565b6001600160a01b03821660009081526020819052604090205460ff1680156104b85792915050565b6001546001600160a01b03161561053e57600154604051631846d2f560e31b81526001600160a01b0385811660048301529091169063c23697a890602401602060405180830381865afa158015610513573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061053791906106df565b9392505050565b50600092915050565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316331461058f5760405162461bcd60e51b815260040161021b906106a9565b600180546001600160a01b0319166001600160a01b0383169081179091556040519081527fbbb218c618aff8fc737012ba779aad87c1cf7a60b501bd2f72bb570517f02ca390602001610272565b6000602082840312156105ef57600080fd5b81356001600160a01b038116811461053757600080fd5b6000806020838503121561061957600080fd5b823567ffffffffffffffff8082111561063157600080fd5b818501915085601f83011261064557600080fd5b81358181111561065457600080fd5b8660208260051b850101111561066957600080fd5b60209290920196919550909350505050565b801515811461068957600080fd5b50565b60006020828403121561069e57600080fd5b81356105378161067b565b60208082526006908201526510b0b236b4b760d11b604082015260600190565b634e487b7160e01b600052603260045260246000fd5b6000602082840312156106f157600080fd5b81516105378161067b56fea26469706673582212205ce205a4575174bf41d715179b300702d85eae262bc2732b0cb6d1c8c6358bcc64736f6c63430008120033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/abis/THX.json b/apps/api/src/app/contracts/abis/THX.json new file mode 100644 index 000000000..56d838db6 --- /dev/null +++ b/apps/api/src/app/contracts/abis/THX.json @@ -0,0 +1,297 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "THX", + "sourceName": "contracts/mock/THX.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256" + } + ], + "name": "increaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x60806040523480156200001157600080fd5b5060405162000cf738038062000cf7833981810160405260408110156200003757600080fd5b5080516020918201516040805180820182526011815270544858204e6574776f726b2028506f532960781b81860190815282518084019093526003808452620a890b60eb1b968401969096528151949593949193620000999290919062000249565b508051620000af90600490602084019062000249565b50506005805460ff1916601217905550620000cb8282620000d3565b5050620002f5565b6001600160a01b0382166200012f576040805162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015290519081900360640190fd5b6200013d60008383620001e2565b6200015981600254620001e760201b620005731790919060201c565b6002556001600160a01b038216600090815260208181526040909120546200018c91839062000573620001e7821b17901c565b6001600160a01b0383166000818152602081815260408083209490945583518581529351929391927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9281900390910190a35050565b505050565b60008282018381101562000242576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b828054600181600116156101000203166002900490600052602060002090601f016020900481019282620002815760008555620002cc565b82601f106200029c57805160ff1916838001178555620002cc565b82800160010185558215620002cc579182015b82811115620002cc578251825591602001919060010190620002af565b50620002da929150620002de565b5090565b5b80821115620002da5760008155600101620002df565b6109f280620003056000396000f3fe608060405234801561001057600080fd5b50600436106100a95760003560e01c8063395093511161007157806339509351146101d957806370a082311461020557806395d89b411461022b578063a457c2d714610233578063a9059cbb1461025f578063dd62ed3e1461028b576100a9565b806306fdde03146100ae578063095ea7b31461012b57806318160ddd1461016b57806323b872dd14610185578063313ce567146101bb575b600080fd5b6100b66102b9565b6040805160208082528351818301528351919283929083019185019080838360005b838110156100f05781810151838201526020016100d8565b50505050905090810190601f16801561011d5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6101576004803603604081101561014157600080fd5b506001600160a01b03813516906020013561034f565b604080519115158252519081900360200190f35b61017361036c565b60408051918252519081900360200190f35b6101576004803603606081101561019b57600080fd5b506001600160a01b03813581169160208101359091169060400135610372565b6101c36103f9565b6040805160ff9092168252519081900360200190f35b610157600480360360408110156101ef57600080fd5b506001600160a01b038135169060200135610402565b6101736004803603602081101561021b57600080fd5b50356001600160a01b0316610450565b6100b661046b565b6101576004803603604081101561024957600080fd5b506001600160a01b0381351690602001356104cc565b6101576004803603604081101561027557600080fd5b506001600160a01b038135169060200135610534565b610173600480360360408110156102a157600080fd5b506001600160a01b0381358116916020013516610548565b60038054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156103455780601f1061031a57610100808354040283529160200191610345565b820191906000526020600020905b81548152906001019060200180831161032857829003601f168201915b5050505050905090565b600061036361035c6105d4565b84846105d8565b50600192915050565b60025490565b600061037f8484846106c4565b6103ef8461038b6105d4565b6103ea85604051806060016040528060288152602001610927602891396001600160a01b038a166000908152600160205260408120906103c96105d4565b6001600160a01b03168152602081019190915260400160002054919061081f565b6105d8565b5060019392505050565b60055460ff1690565b600061036361040f6105d4565b846103ea85600160006104206105d4565b6001600160a01b03908116825260208083019390935260409182016000908120918c168152925290205490610573565b6001600160a01b031660009081526020819052604090205490565b60048054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156103455780601f1061031a57610100808354040283529160200191610345565b60006103636104d96105d4565b846103ea8560405180606001604052806025815260200161099860259139600160006105036105d4565b6001600160a01b03908116825260208083019390935260409182016000908120918d1681529252902054919061081f565b60006103636105416105d4565b84846106c4565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b6000828201838110156105cd576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b3390565b6001600160a01b03831661061d5760405162461bcd60e51b81526004018080602001828103825260248152602001806109746024913960400191505060405180910390fd5b6001600160a01b0382166106625760405162461bcd60e51b81526004018080602001828103825260228152602001806108df6022913960400191505060405180910390fd5b6001600160a01b03808416600081815260016020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b6001600160a01b0383166107095760405162461bcd60e51b815260040180806020018281038252602581526020018061094f6025913960400191505060405180910390fd5b6001600160a01b03821661074e5760405162461bcd60e51b81526004018080602001828103825260238152602001806108bc6023913960400191505060405180910390fd5b6107598383836108b6565b61079681604051806060016040528060268152602001610901602691396001600160a01b038616600090815260208190526040902054919061081f565b6001600160a01b0380851660009081526020819052604080822093909355908416815220546107c59082610573565b6001600160a01b038084166000818152602081815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600081848411156108ae5760405162461bcd60e51b81526004018080602001828103825283818151815260200191508051906020019080838360005b8381101561087357818101518382015260200161085b565b50505050905090810190601f1680156108a05780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b505050900390565b50505056fe45524332303a207472616e7366657220746f20746865207a65726f206164647265737345524332303a20617070726f766520746f20746865207a65726f206164647265737345524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e636545524332303a207472616e7366657220616d6f756e74206578636565647320616c6c6f77616e636545524332303a207472616e736665722066726f6d20746865207a65726f206164647265737345524332303a20617070726f76652066726f6d20746865207a65726f206164647265737345524332303a2064656372656173656420616c6c6f77616e63652062656c6f77207a65726fa264697066735822122077c110b83655fc51968e38fdc606b94ac5819151c022a8cf8ebfabe88559128464736f6c63430007060033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100a95760003560e01c8063395093511161007157806339509351146101d957806370a082311461020557806395d89b411461022b578063a457c2d714610233578063a9059cbb1461025f578063dd62ed3e1461028b576100a9565b806306fdde03146100ae578063095ea7b31461012b57806318160ddd1461016b57806323b872dd14610185578063313ce567146101bb575b600080fd5b6100b66102b9565b6040805160208082528351818301528351919283929083019185019080838360005b838110156100f05781810151838201526020016100d8565b50505050905090810190601f16801561011d5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6101576004803603604081101561014157600080fd5b506001600160a01b03813516906020013561034f565b604080519115158252519081900360200190f35b61017361036c565b60408051918252519081900360200190f35b6101576004803603606081101561019b57600080fd5b506001600160a01b03813581169160208101359091169060400135610372565b6101c36103f9565b6040805160ff9092168252519081900360200190f35b610157600480360360408110156101ef57600080fd5b506001600160a01b038135169060200135610402565b6101736004803603602081101561021b57600080fd5b50356001600160a01b0316610450565b6100b661046b565b6101576004803603604081101561024957600080fd5b506001600160a01b0381351690602001356104cc565b6101576004803603604081101561027557600080fd5b506001600160a01b038135169060200135610534565b610173600480360360408110156102a157600080fd5b506001600160a01b0381358116916020013516610548565b60038054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156103455780601f1061031a57610100808354040283529160200191610345565b820191906000526020600020905b81548152906001019060200180831161032857829003601f168201915b5050505050905090565b600061036361035c6105d4565b84846105d8565b50600192915050565b60025490565b600061037f8484846106c4565b6103ef8461038b6105d4565b6103ea85604051806060016040528060288152602001610927602891396001600160a01b038a166000908152600160205260408120906103c96105d4565b6001600160a01b03168152602081019190915260400160002054919061081f565b6105d8565b5060019392505050565b60055460ff1690565b600061036361040f6105d4565b846103ea85600160006104206105d4565b6001600160a01b03908116825260208083019390935260409182016000908120918c168152925290205490610573565b6001600160a01b031660009081526020819052604090205490565b60048054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156103455780601f1061031a57610100808354040283529160200191610345565b60006103636104d96105d4565b846103ea8560405180606001604052806025815260200161099860259139600160006105036105d4565b6001600160a01b03908116825260208083019390935260409182016000908120918d1681529252902054919061081f565b60006103636105416105d4565b84846106c4565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b6000828201838110156105cd576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b3390565b6001600160a01b03831661061d5760405162461bcd60e51b81526004018080602001828103825260248152602001806109746024913960400191505060405180910390fd5b6001600160a01b0382166106625760405162461bcd60e51b81526004018080602001828103825260228152602001806108df6022913960400191505060405180910390fd5b6001600160a01b03808416600081815260016020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b6001600160a01b0383166107095760405162461bcd60e51b815260040180806020018281038252602581526020018061094f6025913960400191505060405180910390fd5b6001600160a01b03821661074e5760405162461bcd60e51b81526004018080602001828103825260238152602001806108bc6023913960400191505060405180910390fd5b6107598383836108b6565b61079681604051806060016040528060268152602001610901602691396001600160a01b038616600090815260208190526040902054919061081f565b6001600160a01b0380851660009081526020819052604080822093909355908416815220546107c59082610573565b6001600160a01b038084166000818152602081815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600081848411156108ae5760405162461bcd60e51b81526004018080602001828103825283818151815260200191508051906020019080838360005b8381101561087357818101518382015260200161085b565b50505050905090810190601f1680156108a05780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b505050900390565b50505056fe45524332303a207472616e7366657220746f20746865207a65726f206164647265737345524332303a20617070726f766520746f20746865207a65726f206164647265737345524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e636545524332303a207472616e7366657220616d6f756e74206578636565647320616c6c6f77616e636545524332303a207472616e736665722066726f6d20746865207a65726f206164647265737345524332303a20617070726f76652066726f6d20746865207a65726f206164647265737345524332303a2064656372656173656420616c6c6f77616e63652062656c6f77207a65726fa264697066735822122077c110b83655fc51968e38fdc606b94ac5819151c022a8cf8ebfabe88559128464736f6c63430007060033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/abis/THXPaymentSplitter.json b/apps/api/src/app/contracts/abis/THXPaymentSplitter.json new file mode 100644 index 000000000..db415a73b --- /dev/null +++ b/apps/api/src/app/contracts/abis/THXPaymentSplitter.json @@ -0,0 +1,184 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "THXPaymentSplitter", + "sourceName": "contracts/utils/THXPaymentSplitter.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "_admin", + "type": "address" + }, + { + "internalType": "address", + "name": "_registry", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_owner", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "bpt", + "outputs": [ + { + "internalType": "contract IWeightedPool", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_owner", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_minAmountOut", + "type": "uint256" + } + ], + "name": "deposit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "gauge", + "outputs": [ + { + "internalType": "contract IGauge", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "paymentToken", + "outputs": [ + { + "internalType": "contract IERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "rates", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "registry", + "outputs": [ + { + "internalType": "contract ITHXRegistry", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_owner", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_rate", + "type": "uint256" + } + ], + "name": "setRate", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_registry", + "type": "address" + } + ], + "name": "setRegistry", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "vault", + "outputs": [ + { + "internalType": "contract BalancerVault", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + } + ], + "bytecode": "0x60806040523480156200001157600080fd5b506040516200148138038062001481833981016040819052620000349162000304565b6001600081905580546001600160a01b038085166001600160a01b03199283161790925560028054848416921691909117908190556040805163d41c3a6560e01b81529051919092169163d41c3a65916004808301926020929190829003018186803b158015620000a457600080fd5b505afa158015620000b9573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190620000df9190620002e0565b600680546001600160a01b0319166001600160a01b039283161790556002546040805163f2803f0360e01b81529051919092169163f2803f03916004808301926020929190829003018186803b1580156200013957600080fd5b505afa1580156200014e573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190620001749190620002e0565b600380546001600160a01b0319166001600160a01b03928316179081905560408051634163183360e11b8152905191909216916382c63066916004808301926020929190829003018186803b158015620001cd57600080fd5b505afa158015620001e2573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190620002089190620002e0565b600480546001600160a01b0319166001600160a01b0392831617808255604080516311b2515f60e31b815290519190931692638d928af89281810192602092909190829003018186803b1580156200025f57600080fd5b505afa15801562000274573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906200029a9190620002e0565b600580546001600160a01b0319166001600160a01b0392909216919091179055506200033b9050565b80516001600160a01b0381168114620002db57600080fd5b919050565b600060208284031215620002f2578081fd5b620002fd82620002c3565b9392505050565b6000806040838503121562000317578081fd5b6200032283620002c3565b91506200033260208401620002c3565b90509250929050565b611136806200034b6000396000f3fe608060405234801561001057600080fd5b506004361061009e5760003560e01c80637b103999116100665780637b10399914610111578063a6f19c8414610119578063a8734f0b14610121578063a91ee0dc14610134578063fbfa77cf146101475761009e565b80630efe6a8b146100a35780632bdb7097146100b85780633013ce29146100cb578063546af3c3146100e957806370a08231146100f1575b600080fd5b6100b66100b1366004610de6565b61014f565b005b6100b66100c6366004610dbb565b610a46565b6100d3610a9c565b6040516100e09190610edd565b60405180910390f35b6100d3610aab565b6101046100ff366004610d83565b610aba565b6040516100e091906110be565b6100d3610b64565b6100d3610b73565b61010461012f366004610d83565b610b82565b6100b6610142366004610d83565b610b94565b6100d3610bf0565b600260005414156101a7576040805162461bcd60e51b815260206004820152601f60248201527f5265656e7472616e637947756172643a207265656e7472616e742063616c6c00604482015290519081900360640190fd5b600260009081556001600160a01b0384168152600760205260409020546101e95760405162461bcd60e51b81526004016101e090611057565b60405180910390fd5b6006546040516323b872dd60e01b81526001600160a01b03909116906323b872dd9061021d90869030908790600401610ef1565b602060405180830381600087803b15801561023757600080fd5b505af115801561024b573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061026f9190610e1a565b61028b5760405162461bcd60e51b81526004016101e090611022565b60025460408051637a3d22ad60e11b815290516000926001600160a01b03169163f47a455a916004808301926020929190829003018186803b1580156102d057600080fd5b505afa1580156102e4573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906103089190610e3a565b9050600061032261271061031c8685610bff565b90610c61565b6006546002546040805163cd44673560e01b815290519394506001600160a01b039283169363a9059cbb939092169163cd44673591600480820192602092909190829003018186803b15801561037757600080fd5b505afa15801561038b573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906103af9190610d9f565b836040518363ffffffff1660e01b81526004016103cd929190610f15565b602060405180830381600087803b1580156103e757600080fd5b505af11580156103fb573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061041f9190610e1a565b61043b5760405162461bcd60e51b81526004016101e090611022565b604080516002808252606082018352600092602083019080368337505060065482519293506001600160a01b03169183915060009061047657fe5b60200260200101906001600160a01b031690816001600160a01b0316815250506000816001815181106104a557fe5b6001600160a01b039290921660209283029190910182015260408051600280825260608201835260009391929091830190803683370190505090506104ea8684610cc8565b816000815181106104f757fe5b60200260200101818152505060008160018151811061051257fe5b602090810291909101015260065460055482516001600160a01b039283169263095ea7b3921690849060009061054457fe5b60200260200101516040518363ffffffff1660e01b8152600401610569929190610f15565b602060405180830381600087803b15801561058357600080fd5b505af1158015610597573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906105bb9190610e1a565b50600554600480546040805163038fff2d60e41b815290516001600160a01b039485169463b95cac28949316926338fff2d092808201926020929091829003018186803b15801561060b57600080fd5b505afa15801561061f573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906106439190610e3a565b303060405180608001604052808881526020018781526020016001888d60405160200161067293929190610ff6565b6040516020818303038152906040528152602001600015158152506040518563ffffffff1660e01b81526004016106ac9493929190610f2e565b600060405180830381600087803b1580156106c657600080fd5b505af11580156106da573d6000803e3d6000fd5b5050600480546040516370a0823160e01b8152600094506001600160a01b0390911692506370a082319161071091309101610edd565b60206040518083038186803b15801561072857600080fd5b505afa15801561073c573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906107609190610e3a565b6004805460035460405163095ea7b360e01b81529394506001600160a01b039182169363095ea7b3936107999390921691869101610f15565b602060405180830381600087803b1580156107b357600080fd5b505af11580156107c7573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906107eb9190610e1a565b5060035460405163b6b55f2560e01b81526001600160a01b039091169063b6b55f259061081c9084906004016110be565b600060405180830381600087803b15801561083657600080fd5b505af115801561084a573d6000803e3d6000fd5b50506003546040516370a0823160e01b8152600093506001600160a01b0390911691506370a0823190610881903090600401610edd565b60206040518083038186803b15801561089957600080fd5b505afa1580156108ad573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906108d19190610e3a565b600354600254604080516367906c3960e11b815290519394506001600160a01b039283169363a9059cbb939092169163cf20d87291600480820192602092909190829003018186803b15801561092657600080fd5b505afa15801561093a573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061095e9190610d9f565b836040518363ffffffff1660e01b815260040161097c929190610f15565b602060405180830381600087803b15801561099657600080fd5b505af11580156109aa573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906109ce9190610e1a565b6109ea5760405162461bcd60e51b81526004016101e090611022565b6001600160a01b038916600090815260086020526040902054610a0d9089610d25565b6001600160a01b0390991660009081526008602090815260408083209b909b556009905298892042905550506001909655505050505050565b6001546001600160a01b0316610a5a610d7f565b6001600160a01b031614610a805760405162461bcd60e51b81526004016101e090611087565b6001600160a01b03909116600090815260076020526040902055565b6006546001600160a01b031681565b6004546001600160a01b031681565b6001600160a01b038116600090815260076020908152604080832054600990925282205480610aee57600092505050610b5f565b426000610b0584610aff8486610cc8565b90610bff565b6001600160a01b038716600090815260086020526040902054909150811115610b35576000945050505050610b5f565b6001600160a01b038616600090815260086020526040902054610b589082610cc8565b9450505050505b919050565b6002546001600160a01b031681565b6003546001600160a01b031681565b60076020526000908152604090205481565b6001546001600160a01b0316610ba8610d7f565b6001600160a01b031614610bce5760405162461bcd60e51b81526004016101e090611087565b600280546001600160a01b0319166001600160a01b0392909216919091179055565b6005546001600160a01b031681565b600082610c0e57506000610c5b565b82820282848281610c1b57fe5b0414610c585760405162461bcd60e51b81526004018080602001828103825260218152602001806110e06021913960400191505060405180910390fd5b90505b92915050565b6000808211610cb7576040805162461bcd60e51b815260206004820152601a60248201527f536166654d6174683a206469766973696f6e206279207a65726f000000000000604482015290519081900360640190fd5b818381610cc057fe5b049392505050565b600082821115610d1f576040805162461bcd60e51b815260206004820152601e60248201527f536166654d6174683a207375627472616374696f6e206f766572666c6f770000604482015290519081900360640190fd5b50900390565b600082820183811015610c58576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b3390565b600060208284031215610d94578081fd5b8135610c58816110c7565b600060208284031215610db0578081fd5b8151610c58816110c7565b60008060408385031215610dcd578081fd5b8235610dd8816110c7565b946020939093013593505050565b600080600060608486031215610dfa578081fd5b8335610e05816110c7565b95602085013595506040909401359392505050565b600060208284031215610e2b578081fd5b81518015158114610c58578182fd5b600060208284031215610e4b578081fd5b5051919050565b6000815180845260208085019450808401835b83811015610e8157815187529582019590820190600101610e65565b509495945050505050565b15159052565b60008151808452815b81811015610eb757602081850181015186830182015201610e9b565b81811115610ec85782602083870101525b50601f01601f19169290920160200192915050565b6001600160a01b0391909116815260200190565b6001600160a01b039384168152919092166020820152604081019190915260600190565b6001600160a01b03929092168252602082015260400190565b6000858252602060018060a01b0380871682850152808616604085015260806060850152610100840185516080808701528181518084526101208801915085830193508692505b80831015610f9757835185168252928501926001929092019190850190610f75565b50848801519450607f199350838782030160a0880152610fb78186610e52565b94505050506040850151818584030160c0860152610fd58382610e92565b925050506060840151610feb60e0850182610e8c565b509695505050505050565b600060ff85168252606060208301526110126060830185610e52565b9050826040830152949350505050565b6020808252818101527f5061796d656e7453706c69747465723a207472616e73666572206661696c6564604082015260600190565b6020808252601690820152755061796d656e7453706c69747465723a20217261746560501b604082015260600190565b60208082526017908201527f5061796d656e7453706c69747465723a202161646d696e000000000000000000604082015260600190565b90815260200190565b6001600160a01b03811681146110dc57600080fd5b5056fe536166654d6174683a206d756c7469706c69636174696f6e206f766572666c6f77a2646970667358221220eba06cbcd528d8589e50089c90e52f7dc3fa5e7165ae1e40435ece99c19f65c564736f6c63430007060033", + "deployedBytecode": "0x608060405234801561001057600080fd5b506004361061009e5760003560e01c80637b103999116100665780637b10399914610111578063a6f19c8414610119578063a8734f0b14610121578063a91ee0dc14610134578063fbfa77cf146101475761009e565b80630efe6a8b146100a35780632bdb7097146100b85780633013ce29146100cb578063546af3c3146100e957806370a08231146100f1575b600080fd5b6100b66100b1366004610de6565b61014f565b005b6100b66100c6366004610dbb565b610a46565b6100d3610a9c565b6040516100e09190610edd565b60405180910390f35b6100d3610aab565b6101046100ff366004610d83565b610aba565b6040516100e091906110be565b6100d3610b64565b6100d3610b73565b61010461012f366004610d83565b610b82565b6100b6610142366004610d83565b610b94565b6100d3610bf0565b600260005414156101a7576040805162461bcd60e51b815260206004820152601f60248201527f5265656e7472616e637947756172643a207265656e7472616e742063616c6c00604482015290519081900360640190fd5b600260009081556001600160a01b0384168152600760205260409020546101e95760405162461bcd60e51b81526004016101e090611057565b60405180910390fd5b6006546040516323b872dd60e01b81526001600160a01b03909116906323b872dd9061021d90869030908790600401610ef1565b602060405180830381600087803b15801561023757600080fd5b505af115801561024b573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061026f9190610e1a565b61028b5760405162461bcd60e51b81526004016101e090611022565b60025460408051637a3d22ad60e11b815290516000926001600160a01b03169163f47a455a916004808301926020929190829003018186803b1580156102d057600080fd5b505afa1580156102e4573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906103089190610e3a565b9050600061032261271061031c8685610bff565b90610c61565b6006546002546040805163cd44673560e01b815290519394506001600160a01b039283169363a9059cbb939092169163cd44673591600480820192602092909190829003018186803b15801561037757600080fd5b505afa15801561038b573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906103af9190610d9f565b836040518363ffffffff1660e01b81526004016103cd929190610f15565b602060405180830381600087803b1580156103e757600080fd5b505af11580156103fb573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061041f9190610e1a565b61043b5760405162461bcd60e51b81526004016101e090611022565b604080516002808252606082018352600092602083019080368337505060065482519293506001600160a01b03169183915060009061047657fe5b60200260200101906001600160a01b031690816001600160a01b0316815250506000816001815181106104a557fe5b6001600160a01b039290921660209283029190910182015260408051600280825260608201835260009391929091830190803683370190505090506104ea8684610cc8565b816000815181106104f757fe5b60200260200101818152505060008160018151811061051257fe5b602090810291909101015260065460055482516001600160a01b039283169263095ea7b3921690849060009061054457fe5b60200260200101516040518363ffffffff1660e01b8152600401610569929190610f15565b602060405180830381600087803b15801561058357600080fd5b505af1158015610597573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906105bb9190610e1a565b50600554600480546040805163038fff2d60e41b815290516001600160a01b039485169463b95cac28949316926338fff2d092808201926020929091829003018186803b15801561060b57600080fd5b505afa15801561061f573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906106439190610e3a565b303060405180608001604052808881526020018781526020016001888d60405160200161067293929190610ff6565b6040516020818303038152906040528152602001600015158152506040518563ffffffff1660e01b81526004016106ac9493929190610f2e565b600060405180830381600087803b1580156106c657600080fd5b505af11580156106da573d6000803e3d6000fd5b5050600480546040516370a0823160e01b8152600094506001600160a01b0390911692506370a082319161071091309101610edd565b60206040518083038186803b15801561072857600080fd5b505afa15801561073c573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906107609190610e3a565b6004805460035460405163095ea7b360e01b81529394506001600160a01b039182169363095ea7b3936107999390921691869101610f15565b602060405180830381600087803b1580156107b357600080fd5b505af11580156107c7573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906107eb9190610e1a565b5060035460405163b6b55f2560e01b81526001600160a01b039091169063b6b55f259061081c9084906004016110be565b600060405180830381600087803b15801561083657600080fd5b505af115801561084a573d6000803e3d6000fd5b50506003546040516370a0823160e01b8152600093506001600160a01b0390911691506370a0823190610881903090600401610edd565b60206040518083038186803b15801561089957600080fd5b505afa1580156108ad573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906108d19190610e3a565b600354600254604080516367906c3960e11b815290519394506001600160a01b039283169363a9059cbb939092169163cf20d87291600480820192602092909190829003018186803b15801561092657600080fd5b505afa15801561093a573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061095e9190610d9f565b836040518363ffffffff1660e01b815260040161097c929190610f15565b602060405180830381600087803b15801561099657600080fd5b505af11580156109aa573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906109ce9190610e1a565b6109ea5760405162461bcd60e51b81526004016101e090611022565b6001600160a01b038916600090815260086020526040902054610a0d9089610d25565b6001600160a01b0390991660009081526008602090815260408083209b909b556009905298892042905550506001909655505050505050565b6001546001600160a01b0316610a5a610d7f565b6001600160a01b031614610a805760405162461bcd60e51b81526004016101e090611087565b6001600160a01b03909116600090815260076020526040902055565b6006546001600160a01b031681565b6004546001600160a01b031681565b6001600160a01b038116600090815260076020908152604080832054600990925282205480610aee57600092505050610b5f565b426000610b0584610aff8486610cc8565b90610bff565b6001600160a01b038716600090815260086020526040902054909150811115610b35576000945050505050610b5f565b6001600160a01b038616600090815260086020526040902054610b589082610cc8565b9450505050505b919050565b6002546001600160a01b031681565b6003546001600160a01b031681565b60076020526000908152604090205481565b6001546001600160a01b0316610ba8610d7f565b6001600160a01b031614610bce5760405162461bcd60e51b81526004016101e090611087565b600280546001600160a01b0319166001600160a01b0392909216919091179055565b6005546001600160a01b031681565b600082610c0e57506000610c5b565b82820282848281610c1b57fe5b0414610c585760405162461bcd60e51b81526004018080602001828103825260218152602001806110e06021913960400191505060405180910390fd5b90505b92915050565b6000808211610cb7576040805162461bcd60e51b815260206004820152601a60248201527f536166654d6174683a206469766973696f6e206279207a65726f000000000000604482015290519081900360640190fd5b818381610cc057fe5b049392505050565b600082821115610d1f576040805162461bcd60e51b815260206004820152601e60248201527f536166654d6174683a207375627472616374696f6e206f766572666c6f770000604482015290519081900360640190fd5b50900390565b600082820183811015610c58576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b3390565b600060208284031215610d94578081fd5b8135610c58816110c7565b600060208284031215610db0578081fd5b8151610c58816110c7565b60008060408385031215610dcd578081fd5b8235610dd8816110c7565b946020939093013593505050565b600080600060608486031215610dfa578081fd5b8335610e05816110c7565b95602085013595506040909401359392505050565b600060208284031215610e2b578081fd5b81518015158114610c58578182fd5b600060208284031215610e4b578081fd5b5051919050565b6000815180845260208085019450808401835b83811015610e8157815187529582019590820190600101610e65565b509495945050505050565b15159052565b60008151808452815b81811015610eb757602081850181015186830182015201610e9b565b81811115610ec85782602083870101525b50601f01601f19169290920160200192915050565b6001600160a01b0391909116815260200190565b6001600160a01b039384168152919092166020820152604081019190915260600190565b6001600160a01b03929092168252602082015260400190565b6000858252602060018060a01b0380871682850152808616604085015260806060850152610100840185516080808701528181518084526101208801915085830193508692505b80831015610f9757835185168252928501926001929092019190850190610f75565b50848801519450607f199350838782030160a0880152610fb78186610e52565b94505050506040850151818584030160c0860152610fd58382610e92565b925050506060840151610feb60e0850182610e8c565b509695505050505050565b600060ff85168252606060208301526110126060830185610e52565b9050826040830152949350505050565b6020808252818101527f5061796d656e7453706c69747465723a207472616e73666572206661696c6564604082015260600190565b6020808252601690820152755061796d656e7453706c69747465723a20217261746560501b604082015260600190565b60208082526017908201527f5061796d656e7453706c69747465723a202161646d696e000000000000000000604082015260600190565b90815260200190565b6001600160a01b03811681146110dc57600080fd5b5056fe536166654d6174683a206d756c7469706c69636174696f6e206f766572666c6f77a2646970667358221220eba06cbcd528d8589e50089c90e52f7dc3fa5e7165ae1e40435ece99c19f65c564736f6c63430007060033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/abis/THXRegistry.json b/apps/api/src/app/contracts/abis/THXRegistry.json new file mode 100644 index 000000000..c91fd4daa --- /dev/null +++ b/apps/api/src/app/contracts/abis/THXRegistry.json @@ -0,0 +1,180 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "THXRegistry", + "sourceName": "contracts/utils/THXRegistry.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "_paymentToken", + "type": "address" + }, + { + "internalType": "address", + "name": "_payee", + "type": "address" + }, + { + "internalType": "address", + "name": "_rewardDistributor", + "type": "address" + }, + { + "internalType": "address", + "name": "_gauge", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "gauge", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getGauge", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getPayee", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getPaymentToken", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getPayoutRate", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getRewardDistributor", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "payee", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "paymentToken", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "payoutRate", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "rewardDistributor", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_rate", + "type": "uint256" + } + ], + "name": "setPayoutRate", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x608060405234801561001057600080fd5b506040516103683803806103688339818101604052608081101561003357600080fd5b50805160208201516040830151606090930151600080546001600160a01b039485166001600160a01b03199182161790915560018054938516938216939093179092556003805494841694831694909417909355600480549290931691161790556102c5806100a36000396000f3fe608060405234801561001057600080fd5b50600436106100a95760003560e01c8063ae90b21311610071578063ae90b2131461011b578063cd44673514610123578063cf20d8721461012b578063d41c3a6514610133578063f2803f031461013b578063f47a455a14610143576100a9565b80632b4353f2146100ae5780633013ce29146100c857806348eaee66146100ec578063a6f19c841461010b578063acc2166a14610113575b600080fd5b6100b661014b565b60408051918252519081900360200190f35b6100d0610151565b604080516001600160a01b039092168252519081900360200190f35b6101096004803603602081101561010257600080fd5b5035610160565b005b6100d06101fb565b6100d061020a565b6100d0610219565b6100d0610228565b6100d0610237565b6100d0610246565b6100d0610255565b6100b6610264565b60025481565b6000546001600160a01b031681565b6001546001600160a01b031633146101b5576040805162461bcd60e51b815260206004820152601360248201527254485852656769737472793a2021706179656560681b604482015290519081900360640190fd5b6127108111156101f65760405162461bcd60e51b815260040180806020018281038252602581526020018061026b6025913960400191505060405180910390fd5b600255565b6004546001600160a01b031681565b6003546001600160a01b031681565b6001546001600160a01b031681565b6001546001600160a01b031690565b6003546001600160a01b031690565b6000546001600160a01b031690565b6004546001600160a01b031690565b6002549056fe54485852656769737472793a207061796f757452617465206f7574206f6620626f756e6473a26469706673582212209766fd3b31b5070d7ebcdb80534a3fdd12da7399c3e94c56888c415f0e1ef47064736f6c63430007060033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100a95760003560e01c8063ae90b21311610071578063ae90b2131461011b578063cd44673514610123578063cf20d8721461012b578063d41c3a6514610133578063f2803f031461013b578063f47a455a14610143576100a9565b80632b4353f2146100ae5780633013ce29146100c857806348eaee66146100ec578063a6f19c841461010b578063acc2166a14610113575b600080fd5b6100b661014b565b60408051918252519081900360200190f35b6100d0610151565b604080516001600160a01b039092168252519081900360200190f35b6101096004803603602081101561010257600080fd5b5035610160565b005b6100d06101fb565b6100d061020a565b6100d0610219565b6100d0610228565b6100d0610237565b6100d0610246565b6100d0610255565b6100b6610264565b60025481565b6000546001600160a01b031681565b6001546001600160a01b031633146101b5576040805162461bcd60e51b815260206004820152601360248201527254485852656769737472793a2021706179656560681b604482015290519081900360640190fd5b6127108111156101f65760405162461bcd60e51b815260040180806020018281038252602581526020018061026b6025913960400191505060405180910390fd5b600255565b6004546001600160a01b031681565b6003546001600160a01b031681565b6001546001600160a01b031681565b6001546001600160a01b031690565b6003546001600160a01b031690565b6000546001600160a01b031690565b6004546001600160a01b031690565b6002549056fe54485852656769737472793a207061796f757452617465206f7574206f6620626f756e6473a26469706673582212209766fd3b31b5070d7ebcdb80534a3fdd12da7399c3e94c56888c415f0e1ef47064736f6c63430007060033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/abis/THX_ERC1155.json b/apps/api/src/app/contracts/abis/THX_ERC1155.json new file mode 100644 index 000000000..4b570f257 --- /dev/null +++ b/apps/api/src/app/contracts/abis/THX_ERC1155.json @@ -0,0 +1,679 @@ +[ + { + "inputs": [ + { + "internalType": "string", + "name": "URI_", + "type": "string" + }, + { + "internalType": "address", + "name": "owner_", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "ApprovalForAll", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "previousAdminRole", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "newAdminRole", + "type": "bytes32" + } + ], + "name": "RoleAdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleGranted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleRevoked", + "type": "event" + }, + { + "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": "ids", + "type": "uint256[]" + }, + { + "indexed": false, + "internalType": "uint256[]", + "name": "values", + "type": "uint256[]" + } + ], + "name": "TransferBatch", + "type": "event" + }, + { + "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" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "string", + "name": "value", + "type": "string" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "id", + "type": "uint256" + } + ], + "name": "URI", + "type": "event" + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MINTER_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "uint256", + "name": "id", + "type": "uint256" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "accounts", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "ids", + "type": "uint256[]" + } + ], + "name": "balanceOfBatch", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleAdmin", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "getRoleMember", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleMemberCount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "grantRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "hasRole", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "address", + "name": "operator", + "type": "address" + } + ], + "name": "isApprovedForAll", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "id", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256[]", + "name": "ids", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "amounts", + "type": "uint256[]" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "mintBatch", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "renounceRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "revokeRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256[]", + "name": "ids", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "amounts", + "type": "uint256[]" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "safeBatchTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "id", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "safeTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "setApprovalForAll", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "uri", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + } +] \ No newline at end of file diff --git a/apps/api/src/app/contracts/abis/TestToken.json b/apps/api/src/app/contracts/abis/TestToken.json new file mode 100644 index 000000000..0ea496224 --- /dev/null +++ b/apps/api/src/app/contracts/abis/TestToken.json @@ -0,0 +1,304 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "TestToken", + "sourceName": "contracts/mock/Token.sol", + "abi": [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256" + } + ], + "name": "increaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x608060405234801561001057600080fd5b5060405180604001604052806006815260200165546f6b656e3160d01b8152506040518060400160405280600681526020016553796d626c3160d01b815250816003908161005e9190610112565b50600461006b8282610112565b5050506101d1565b634e487b7160e01b600052604160045260246000fd5b600181811c9082168061009d57607f821691505b6020821081036100bd57634e487b7160e01b600052602260045260246000fd5b50919050565b601f82111561010d57600081815260208120601f850160051c810160208610156100ea5750805b601f850160051c820191505b81811015610109578281556001016100f6565b5050505b505050565b81516001600160401b0381111561012b5761012b610073565b61013f816101398454610089565b846100c3565b602080601f831160018114610174576000841561015c5750858301515b600019600386901b1c1916600185901b178555610109565b600085815260208120601f198616915b828110156101a357888601518255948401946001909101908401610184565b50858210156101c15787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b61093f806101e06000396000f3fe608060405234801561001057600080fd5b50600436106100b45760003560e01c806340c10f191161007157806340c10f191461014157806370a082311461015657806395d89b411461017f578063a457c2d714610187578063a9059cbb1461019a578063dd62ed3e146101ad57600080fd5b806306fdde03146100b9578063095ea7b3146100d757806318160ddd146100fa57806323b872dd1461010c578063313ce5671461011f578063395093511461012e575b600080fd5b6100c16101c0565b6040516100ce9190610789565b60405180910390f35b6100ea6100e53660046107f3565b610252565b60405190151581526020016100ce565b6002545b6040519081526020016100ce565b6100ea61011a36600461081d565b61026c565b604051601281526020016100ce565b6100ea61013c3660046107f3565b610290565b61015461014f3660046107f3565b6102b2565b005b6100fe610164366004610859565b6001600160a01b031660009081526020819052604090205490565b6100c16102c0565b6100ea6101953660046107f3565b6102cf565b6100ea6101a83660046107f3565b61034f565b6100fe6101bb36600461087b565b61035d565b6060600380546101cf906108ae565b80601f01602080910402602001604051908101604052809291908181526020018280546101fb906108ae565b80156102485780601f1061021d57610100808354040283529160200191610248565b820191906000526020600020905b81548152906001019060200180831161022b57829003601f168201915b5050505050905090565b600033610260818585610388565b60019150505b92915050565b60003361027a8582856104ac565b610285858585610526565b506001949350505050565b6000336102608185856102a3838361035d565b6102ad91906108e8565b610388565b6102bc82826106ca565b5050565b6060600480546101cf906108ae565b600033816102dd828661035d565b9050838110156103425760405162461bcd60e51b815260206004820152602560248201527f45524332303a2064656372656173656420616c6c6f77616e63652062656c6f77604482015264207a65726f60d81b60648201526084015b60405180910390fd5b6102858286868403610388565b600033610260818585610526565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b6001600160a01b0383166103ea5760405162461bcd60e51b8152602060048201526024808201527f45524332303a20617070726f76652066726f6d20746865207a65726f206164646044820152637265737360e01b6064820152608401610339565b6001600160a01b03821661044b5760405162461bcd60e51b815260206004820152602260248201527f45524332303a20617070726f766520746f20746865207a65726f206164647265604482015261737360f01b6064820152608401610339565b6001600160a01b0383811660008181526001602090815260408083209487168084529482529182902085905590518481527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925910160405180910390a3505050565b60006104b8848461035d565b9050600019811461052057818110156105135760405162461bcd60e51b815260206004820152601d60248201527f45524332303a20696e73756666696369656e7420616c6c6f77616e63650000006044820152606401610339565b6105208484848403610388565b50505050565b6001600160a01b03831661058a5760405162461bcd60e51b815260206004820152602560248201527f45524332303a207472616e736665722066726f6d20746865207a65726f206164604482015264647265737360d81b6064820152608401610339565b6001600160a01b0382166105ec5760405162461bcd60e51b815260206004820152602360248201527f45524332303a207472616e7366657220746f20746865207a65726f206164647260448201526265737360e81b6064820152608401610339565b6001600160a01b038316600090815260208190526040902054818110156106645760405162461bcd60e51b815260206004820152602660248201527f45524332303a207472616e7366657220616d6f756e7420657863656564732062604482015265616c616e636560d01b6064820152608401610339565b6001600160a01b03848116600081815260208181526040808320878703905593871680835291849020805487019055925185815290927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a3610520565b6001600160a01b0382166107205760405162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f2061646472657373006044820152606401610339565b806002600082825461073291906108e8565b90915550506001600160a01b038216600081815260208181526040808320805486019055518481527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35050565b600060208083528351808285015260005b818110156107b65785810183015185820160400152820161079a565b506000604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b03811681146107ee57600080fd5b919050565b6000806040838503121561080657600080fd5b61080f836107d7565b946020939093013593505050565b60008060006060848603121561083257600080fd5b61083b846107d7565b9250610849602085016107d7565b9150604084013590509250925092565b60006020828403121561086b57600080fd5b610874826107d7565b9392505050565b6000806040838503121561088e57600080fd5b610897836107d7565b91506108a5602084016107d7565b90509250929050565b600181811c908216806108c257607f821691505b6020821081036108e257634e487b7160e01b600052602260045260246000fd5b50919050565b8082018082111561026657634e487b7160e01b600052601160045260246000fdfea264697066735822122061db66c492e91059f7307777f5295b8df5b39f9055c2a3cb521f545cfe1b69c764736f6c63430008120033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100b45760003560e01c806340c10f191161007157806340c10f191461014157806370a082311461015657806395d89b411461017f578063a457c2d714610187578063a9059cbb1461019a578063dd62ed3e146101ad57600080fd5b806306fdde03146100b9578063095ea7b3146100d757806318160ddd146100fa57806323b872dd1461010c578063313ce5671461011f578063395093511461012e575b600080fd5b6100c16101c0565b6040516100ce9190610789565b60405180910390f35b6100ea6100e53660046107f3565b610252565b60405190151581526020016100ce565b6002545b6040519081526020016100ce565b6100ea61011a36600461081d565b61026c565b604051601281526020016100ce565b6100ea61013c3660046107f3565b610290565b61015461014f3660046107f3565b6102b2565b005b6100fe610164366004610859565b6001600160a01b031660009081526020819052604090205490565b6100c16102c0565b6100ea6101953660046107f3565b6102cf565b6100ea6101a83660046107f3565b61034f565b6100fe6101bb36600461087b565b61035d565b6060600380546101cf906108ae565b80601f01602080910402602001604051908101604052809291908181526020018280546101fb906108ae565b80156102485780601f1061021d57610100808354040283529160200191610248565b820191906000526020600020905b81548152906001019060200180831161022b57829003601f168201915b5050505050905090565b600033610260818585610388565b60019150505b92915050565b60003361027a8582856104ac565b610285858585610526565b506001949350505050565b6000336102608185856102a3838361035d565b6102ad91906108e8565b610388565b6102bc82826106ca565b5050565b6060600480546101cf906108ae565b600033816102dd828661035d565b9050838110156103425760405162461bcd60e51b815260206004820152602560248201527f45524332303a2064656372656173656420616c6c6f77616e63652062656c6f77604482015264207a65726f60d81b60648201526084015b60405180910390fd5b6102858286868403610388565b600033610260818585610526565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b6001600160a01b0383166103ea5760405162461bcd60e51b8152602060048201526024808201527f45524332303a20617070726f76652066726f6d20746865207a65726f206164646044820152637265737360e01b6064820152608401610339565b6001600160a01b03821661044b5760405162461bcd60e51b815260206004820152602260248201527f45524332303a20617070726f766520746f20746865207a65726f206164647265604482015261737360f01b6064820152608401610339565b6001600160a01b0383811660008181526001602090815260408083209487168084529482529182902085905590518481527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925910160405180910390a3505050565b60006104b8848461035d565b9050600019811461052057818110156105135760405162461bcd60e51b815260206004820152601d60248201527f45524332303a20696e73756666696369656e7420616c6c6f77616e63650000006044820152606401610339565b6105208484848403610388565b50505050565b6001600160a01b03831661058a5760405162461bcd60e51b815260206004820152602560248201527f45524332303a207472616e736665722066726f6d20746865207a65726f206164604482015264647265737360d81b6064820152608401610339565b6001600160a01b0382166105ec5760405162461bcd60e51b815260206004820152602360248201527f45524332303a207472616e7366657220746f20746865207a65726f206164647260448201526265737360e81b6064820152608401610339565b6001600160a01b038316600090815260208190526040902054818110156106645760405162461bcd60e51b815260206004820152602660248201527f45524332303a207472616e7366657220616d6f756e7420657863656564732062604482015265616c616e636560d01b6064820152608401610339565b6001600160a01b03848116600081815260208181526040808320878703905593871680835291849020805487019055925185815290927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a3610520565b6001600160a01b0382166107205760405162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f2061646472657373006044820152606401610339565b806002600082825461073291906108e8565b90915550506001600160a01b038216600081815260208181526040808320805486019055518481527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35050565b600060208083528351808285015260005b818110156107b65785810183015185820160400152820161079a565b506000604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b03811681146107ee57600080fd5b919050565b6000806040838503121561080657600080fd5b61080f836107d7565b946020939093013593505050565b60008060006060848603121561083257600080fd5b61083b846107d7565b9250610849602085016107d7565b9150604084013590509250925092565b60006020828403121561086b57600080fd5b610874826107d7565b9392505050565b6000806040838503121561088e57600080fd5b610897836107d7565b91506108a5602084016107d7565b90509250929050565b600181811c908216806108c257607f821691505b6020821081036108e257634e487b7160e01b600052602260045260246000fd5b50919050565b8082018082111561026657634e487b7160e01b600052601160045260246000fdfea264697066735822122061db66c492e91059f7307777f5295b8df5b39f9055c2a3cb521f545cfe1b69c764736f6c63430008120033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/abis/USDC.json b/apps/api/src/app/contracts/abis/USDC.json new file mode 100644 index 000000000..56669e317 --- /dev/null +++ b/apps/api/src/app/contracts/abis/USDC.json @@ -0,0 +1,297 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "USDC", + "sourceName": "contracts/mock/USDC.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256" + } + ], + "name": "increaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x60806040523480156200001157600080fd5b5060405162000d1938038062000d19833981810160405260408110156200003757600080fd5b508051602091820151604080518082018252600e81526d55534420436f696e2028506f532960901b81860190815282518084019093526006835265555344432e6560d01b9583019590955280519394929390926200009991600391906200026b565b508051620000af9060049060208401906200026b565b50506005805460ff1916601217905550620000cb6006620000df565b620000d78282620000f5565b505062000317565b6005805460ff191660ff92909216919091179055565b6001600160a01b03821662000151576040805162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015290519081900360640190fd5b6200015f6000838362000204565b6200017b816002546200020960201b620005731790919060201c565b6002556001600160a01b03821660009081526020818152604090912054620001ae9183906200057362000209821b17901c565b6001600160a01b0383166000818152602081815260408083209490945583518581529351929391927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9281900390910190a35050565b505050565b60008282018381101562000264576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b828054600181600116156101000203166002900490600052602060002090601f016020900481019282620002a35760008555620002ee565b82601f10620002be57805160ff1916838001178555620002ee565b82800160010185558215620002ee579182015b82811115620002ee578251825591602001919060010190620002d1565b50620002fc92915062000300565b5090565b5b80821115620002fc576000815560010162000301565b6109f280620003276000396000f3fe608060405234801561001057600080fd5b50600436106100a95760003560e01c8063395093511161007157806339509351146101d957806370a082311461020557806395d89b411461022b578063a457c2d714610233578063a9059cbb1461025f578063dd62ed3e1461028b576100a9565b806306fdde03146100ae578063095ea7b31461012b57806318160ddd1461016b57806323b872dd14610185578063313ce567146101bb575b600080fd5b6100b66102b9565b6040805160208082528351818301528351919283929083019185019080838360005b838110156100f05781810151838201526020016100d8565b50505050905090810190601f16801561011d5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6101576004803603604081101561014157600080fd5b506001600160a01b03813516906020013561034f565b604080519115158252519081900360200190f35b61017361036c565b60408051918252519081900360200190f35b6101576004803603606081101561019b57600080fd5b506001600160a01b03813581169160208101359091169060400135610372565b6101c36103f9565b6040805160ff9092168252519081900360200190f35b610157600480360360408110156101ef57600080fd5b506001600160a01b038135169060200135610402565b6101736004803603602081101561021b57600080fd5b50356001600160a01b0316610450565b6100b661046b565b6101576004803603604081101561024957600080fd5b506001600160a01b0381351690602001356104cc565b6101576004803603604081101561027557600080fd5b506001600160a01b038135169060200135610534565b610173600480360360408110156102a157600080fd5b506001600160a01b0381358116916020013516610548565b60038054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156103455780601f1061031a57610100808354040283529160200191610345565b820191906000526020600020905b81548152906001019060200180831161032857829003601f168201915b5050505050905090565b600061036361035c6105d4565b84846105d8565b50600192915050565b60025490565b600061037f8484846106c4565b6103ef8461038b6105d4565b6103ea85604051806060016040528060288152602001610927602891396001600160a01b038a166000908152600160205260408120906103c96105d4565b6001600160a01b03168152602081019190915260400160002054919061081f565b6105d8565b5060019392505050565b60055460ff1690565b600061036361040f6105d4565b846103ea85600160006104206105d4565b6001600160a01b03908116825260208083019390935260409182016000908120918c168152925290205490610573565b6001600160a01b031660009081526020819052604090205490565b60048054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156103455780601f1061031a57610100808354040283529160200191610345565b60006103636104d96105d4565b846103ea8560405180606001604052806025815260200161099860259139600160006105036105d4565b6001600160a01b03908116825260208083019390935260409182016000908120918d1681529252902054919061081f565b60006103636105416105d4565b84846106c4565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b6000828201838110156105cd576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b3390565b6001600160a01b03831661061d5760405162461bcd60e51b81526004018080602001828103825260248152602001806109746024913960400191505060405180910390fd5b6001600160a01b0382166106625760405162461bcd60e51b81526004018080602001828103825260228152602001806108df6022913960400191505060405180910390fd5b6001600160a01b03808416600081815260016020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b6001600160a01b0383166107095760405162461bcd60e51b815260040180806020018281038252602581526020018061094f6025913960400191505060405180910390fd5b6001600160a01b03821661074e5760405162461bcd60e51b81526004018080602001828103825260238152602001806108bc6023913960400191505060405180910390fd5b6107598383836108b6565b61079681604051806060016040528060268152602001610901602691396001600160a01b038616600090815260208190526040902054919061081f565b6001600160a01b0380851660009081526020819052604080822093909355908416815220546107c59082610573565b6001600160a01b038084166000818152602081815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600081848411156108ae5760405162461bcd60e51b81526004018080602001828103825283818151815260200191508051906020019080838360005b8381101561087357818101518382015260200161085b565b50505050905090810190601f1680156108a05780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b505050900390565b50505056fe45524332303a207472616e7366657220746f20746865207a65726f206164647265737345524332303a20617070726f766520746f20746865207a65726f206164647265737345524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e636545524332303a207472616e7366657220616d6f756e74206578636565647320616c6c6f77616e636545524332303a207472616e736665722066726f6d20746865207a65726f206164647265737345524332303a20617070726f76652066726f6d20746865207a65726f206164647265737345524332303a2064656372656173656420616c6c6f77616e63652062656c6f77207a65726fa26469706673582212201f8cdee0bd0e245ffd8ccb83c246c0b366118d610a54acf6eeed476dca2dfa1964736f6c63430007060033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100a95760003560e01c8063395093511161007157806339509351146101d957806370a082311461020557806395d89b411461022b578063a457c2d714610233578063a9059cbb1461025f578063dd62ed3e1461028b576100a9565b806306fdde03146100ae578063095ea7b31461012b57806318160ddd1461016b57806323b872dd14610185578063313ce567146101bb575b600080fd5b6100b66102b9565b6040805160208082528351818301528351919283929083019185019080838360005b838110156100f05781810151838201526020016100d8565b50505050905090810190601f16801561011d5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6101576004803603604081101561014157600080fd5b506001600160a01b03813516906020013561034f565b604080519115158252519081900360200190f35b61017361036c565b60408051918252519081900360200190f35b6101576004803603606081101561019b57600080fd5b506001600160a01b03813581169160208101359091169060400135610372565b6101c36103f9565b6040805160ff9092168252519081900360200190f35b610157600480360360408110156101ef57600080fd5b506001600160a01b038135169060200135610402565b6101736004803603602081101561021b57600080fd5b50356001600160a01b0316610450565b6100b661046b565b6101576004803603604081101561024957600080fd5b506001600160a01b0381351690602001356104cc565b6101576004803603604081101561027557600080fd5b506001600160a01b038135169060200135610534565b610173600480360360408110156102a157600080fd5b506001600160a01b0381358116916020013516610548565b60038054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156103455780601f1061031a57610100808354040283529160200191610345565b820191906000526020600020905b81548152906001019060200180831161032857829003601f168201915b5050505050905090565b600061036361035c6105d4565b84846105d8565b50600192915050565b60025490565b600061037f8484846106c4565b6103ef8461038b6105d4565b6103ea85604051806060016040528060288152602001610927602891396001600160a01b038a166000908152600160205260408120906103c96105d4565b6001600160a01b03168152602081019190915260400160002054919061081f565b6105d8565b5060019392505050565b60055460ff1690565b600061036361040f6105d4565b846103ea85600160006104206105d4565b6001600160a01b03908116825260208083019390935260409182016000908120918c168152925290205490610573565b6001600160a01b031660009081526020819052604090205490565b60048054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156103455780601f1061031a57610100808354040283529160200191610345565b60006103636104d96105d4565b846103ea8560405180606001604052806025815260200161099860259139600160006105036105d4565b6001600160a01b03908116825260208083019390935260409182016000908120918d1681529252902054919061081f565b60006103636105416105d4565b84846106c4565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b6000828201838110156105cd576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b3390565b6001600160a01b03831661061d5760405162461bcd60e51b81526004018080602001828103825260248152602001806109746024913960400191505060405180910390fd5b6001600160a01b0382166106625760405162461bcd60e51b81526004018080602001828103825260228152602001806108df6022913960400191505060405180910390fd5b6001600160a01b03808416600081815260016020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b6001600160a01b0383166107095760405162461bcd60e51b815260040180806020018281038252602581526020018061094f6025913960400191505060405180910390fd5b6001600160a01b03821661074e5760405162461bcd60e51b81526004018080602001828103825260238152602001806108bc6023913960400191505060405180910390fd5b6107598383836108b6565b61079681604051806060016040528060268152602001610901602691396001600160a01b038616600090815260208190526040902054919061081f565b6001600160a01b0380851660009081526020819052604080822093909355908416815220546107c59082610573565b6001600160a01b038084166000818152602081815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600081848411156108ae5760405162461bcd60e51b81526004018080602001828103825283818151815260200191508051906020019080838360005b8381101561087357818101518382015260200161085b565b50505050905090810190601f1680156108a05780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b505050900390565b50505056fe45524332303a207472616e7366657220746f20746865207a65726f206164647265737345524332303a20617070726f766520746f20746865207a65726f206164647265737345524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e636545524332303a207472616e7366657220616d6f756e74206578636565647320616c6c6f77616e636545524332303a207472616e736665722066726f6d20746865207a65726f206164647265737345524332303a20617070726f76652066726f6d20746865207a65726f206164647265737345524332303a2064656372656173656420616c6c6f77616e63652062656c6f77207a65726fa26469706673582212201f8cdee0bd0e245ffd8ccb83c246c0b366118d610a54acf6eeed476dca2dfa1964736f6c63430007060033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/abis/UnlimitedSupplyToken.json b/apps/api/src/app/contracts/abis/UnlimitedSupplyToken.json new file mode 100644 index 000000000..3c34bd030 --- /dev/null +++ b/apps/api/src/app/contracts/abis/UnlimitedSupplyToken.json @@ -0,0 +1,586 @@ +[ + { + "inputs": [ + { + "internalType": "string", + "name": "name_", + "type": "string" + }, + { + "internalType": "string", + "name": "symbol_", + "type": "string" + }, + { + "internalType": "address", + "name": "owner_", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "previousAdminRole", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "newAdminRole", + "type": "bytes32" + } + ], + "name": "RoleAdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleGranted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MINTER_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleAdmin", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "getRoleMember", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleMemberCount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "grantRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "hasRole", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256" + } + ], + "name": "increaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "renounceRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "revokeRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/apps/api/src/app/contracts/abis/VotingEscrow.json b/apps/api/src/app/contracts/abis/VotingEscrow.json new file mode 100644 index 000000000..4f72b7728 --- /dev/null +++ b/apps/api/src/app/contracts/abis/VotingEscrow.json @@ -0,0 +1,1028 @@ +{ + "_format": "hh-vyper-artifact-1", + "contractName": "VotingEscrow", + "sourceName": "contracts/VotingEscrow.vy", + "abi": [ + { + "name": "CommitOwnership", + "inputs": [ + { + "name": "admin", + "type": "address", + "indexed": false + } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "ApplyOwnership", + "inputs": [ + { + "name": "admin", + "type": "address", + "indexed": false + } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "EarlyUnlock", + "inputs": [ + { + "name": "status", + "type": "bool", + "indexed": false + } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "PenaltySpeed", + "inputs": [ + { + "name": "penalty_k", + "type": "uint256", + "indexed": false + } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "PenaltyTreasury", + "inputs": [ + { + "name": "penalty_treasury", + "type": "address", + "indexed": false + } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "TotalUnlock", + "inputs": [ + { + "name": "status", + "type": "bool", + "indexed": false + } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "RewardReceiver", + "inputs": [ + { + "name": "newReceiver", + "type": "address", + "indexed": false + } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "Deposit", + "inputs": [ + { + "name": "provider", + "type": "address", + "indexed": true + }, + { + "name": "value", + "type": "uint256", + "indexed": false + }, + { + "name": "locktime", + "type": "uint256", + "indexed": true + }, + { + "name": "type", + "type": "int128", + "indexed": false + }, + { + "name": "ts", + "type": "uint256", + "indexed": false + } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "Withdraw", + "inputs": [ + { + "name": "provider", + "type": "address", + "indexed": true + }, + { + "name": "value", + "type": "uint256", + "indexed": false + }, + { + "name": "ts", + "type": "uint256", + "indexed": false + } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "WithdrawEarly", + "inputs": [ + { + "name": "provider", + "type": "address", + "indexed": true + }, + { + "name": "penalty", + "type": "uint256", + "indexed": false + }, + { + "name": "time_left", + "type": "uint256", + "indexed": false + } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "Supply", + "inputs": [ + { + "name": "prevSupply", + "type": "uint256", + "indexed": false + }, + { + "name": "supply", + "type": "uint256", + "indexed": false + } + ], + "anonymous": false, + "type": "event" + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_token_addr", + "type": "address" + }, + { + "name": "_name", + "type": "string" + }, + { + "name": "_symbol", + "type": "string" + }, + { + "name": "_admin_addr", + "type": "address" + }, + { + "name": "_admin_unlock_all", + "type": "address" + }, + { + "name": "_admin_early_unlock", + "type": "address" + }, + { + "name": "_max_time", + "type": "uint256" + }, + { + "name": "_balToken", + "type": "address" + }, + { + "name": "_balMinter", + "type": "address" + }, + { + "name": "_rewardReceiver", + "type": "address" + }, + { + "name": "_rewardReceiverChangeable", + "type": "bool" + }, + { + "name": "_rewardDistributor", + "type": "address" + } + ], + "outputs": [] + }, + { + "stateMutability": "view", + "type": "function", + "name": "token", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "name", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "symbol", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "decimals", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "commit_transfer_ownership", + "inputs": [ + { + "name": "addr", + "type": "address" + } + ], + "outputs": [] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "apply_transfer_ownership", + "inputs": [], + "outputs": [] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "commit_smart_wallet_checker", + "inputs": [ + { + "name": "addr", + "type": "address" + } + ], + "outputs": [] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "apply_smart_wallet_checker", + "inputs": [], + "outputs": [] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "set_early_unlock", + "inputs": [ + { + "name": "_early_unlock", + "type": "bool" + } + ], + "outputs": [] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "set_early_unlock_penalty_speed", + "inputs": [ + { + "name": "_penalty_k", + "type": "uint256" + } + ], + "outputs": [] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "set_penalty_treasury", + "inputs": [ + { + "name": "_penalty_treasury", + "type": "address" + } + ], + "outputs": [] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "set_all_unlock", + "inputs": [], + "outputs": [] + }, + { + "stateMutability": "view", + "type": "function", + "name": "get_last_user_slope", + "inputs": [ + { + "name": "addr", + "type": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "int128" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "user_point_history__ts", + "inputs": [ + { + "name": "_addr", + "type": "address" + }, + { + "name": "_idx", + "type": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "locked__end", + "inputs": [ + { + "name": "_addr", + "type": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "checkpoint", + "inputs": [], + "outputs": [] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "deposit_for", + "inputs": [ + { + "name": "_addr", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "outputs": [] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "create_lock", + "inputs": [ + { + "name": "_value", + "type": "uint256" + }, + { + "name": "_unlock_time", + "type": "uint256" + } + ], + "outputs": [] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "increase_amount", + "inputs": [ + { + "name": "_value", + "type": "uint256" + } + ], + "outputs": [] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "increase_unlock_time", + "inputs": [ + { + "name": "_unlock_time", + "type": "uint256" + } + ], + "outputs": [] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "withdraw", + "inputs": [], + "outputs": [] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "withdraw_early", + "inputs": [], + "outputs": [] + }, + { + "stateMutability": "view", + "type": "function", + "name": "balanceOf", + "inputs": [ + { + "name": "addr", + "type": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "balanceOf", + "inputs": [ + { + "name": "addr", + "type": "address" + }, + { + "name": "_t", + "type": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "balanceOfAt", + "inputs": [ + { + "name": "addr", + "type": "address" + }, + { + "name": "_block", + "type": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "totalSupply", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "totalSupply", + "inputs": [ + { + "name": "t", + "type": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "totalSupplyAt", + "inputs": [ + { + "name": "_block", + "type": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "claimExternalRewards", + "inputs": [], + "outputs": [] + }, + { + "stateMutability": "nonpayable", + "type": "function", + "name": "changeRewardReceiver", + "inputs": [ + { + "name": "newReceiver", + "type": "address" + } + ], + "outputs": [] + }, + { + "stateMutability": "view", + "type": "function", + "name": "MAXTIME", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "TOKEN", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "supply", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "locked", + "inputs": [ + { + "name": "arg0", + "type": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "components": [ + { + "name": "amount", + "type": "int128" + }, + { + "name": "end", + "type": "uint256" + } + ] + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "epoch", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "point_history", + "inputs": [ + { + "name": "arg0", + "type": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "components": [ + { + "name": "bias", + "type": "int128" + }, + { + "name": "slope", + "type": "int128" + }, + { + "name": "ts", + "type": "uint256" + }, + { + "name": "blk", + "type": "uint256" + } + ] + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "user_point_history", + "inputs": [ + { + "name": "arg0", + "type": "address" + }, + { + "name": "arg1", + "type": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "components": [ + { + "name": "bias", + "type": "int128" + }, + { + "name": "slope", + "type": "int128" + }, + { + "name": "ts", + "type": "uint256" + }, + { + "name": "blk", + "type": "uint256" + } + ] + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "user_point_epoch", + "inputs": [ + { + "name": "arg0", + "type": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "slope_changes", + "inputs": [ + { + "name": "arg0", + "type": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "int128" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "future_smart_wallet_checker", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "smart_wallet_checker", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "admin", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "admin_unlock_all", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "admin_early_unlock", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "future_admin", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "is_initialized", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "early_unlock", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "penalty_k", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "prev_penalty_k", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "penalty_upd_ts", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "penalty_treasury", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "balMinter", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "balToken", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "rewardReceiver", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "rewardReceiverChangeable", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "rewardDistributor", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address" + } + ] + }, + { + "stateMutability": "view", + "type": "function", + "name": "all_unlock", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool" + } + ] + } + ], + "bytecode": "0x613bea61001161000039613bea610000f36003361161000c57612d19565b60003560e01c34613bd857639bf6abf3811861047c576101c43610613bd8576004358060a01c613bd8576040526024356004016040813511613bd8578035806060526020820181816080375050506044356004016020813511613bd85780358060c05260208201803560e0525050506064358060a01c613bd857610100526084358060a01c613bd8576101205260a4358060a01c613bd8576101405260e4358060a01c613bd85761016052610104358060a01c613bd85761018052610124358060a01c613bd8576101a052610144358060011c613bd8576101c052610164358060a01c613bd8576101e0526c050c783eb9b5c84000000000155415610171576009610200527f6f6e6c79206f6e636500000000000000000000000000000000000000000000006102205261020050610200518061022001601f826000031636823750506308c379a06101c05260206101e052601f19601f6102005101166044016101dcfd5b60016c050c783eb9b5c840000000001555610100516101f0576006610200527f21656d70747900000000000000000000000000000000000000000000000000006102205261020050610200518061022001601f826000031636823750506308c379a06101c05260206101e052601f19601f6102005101166044016101dcfd5b610100516c050c783eb9b5c840000000001155600a6c050c783eb9b5c840000000001755600a6c050c783eb9b5c840000000001855426c050c783eb9b5c840000000001955610100516c050c783eb9b5c840000000001a5560405160025543600f5542600e5560405163313ce567610220526020610220600461023c845afa61027e573d600060003e3d6000fd5b60203d10613bd8576102209050516102005260066102005110156102a35760006102ac565b60ff6102005111155b610316576009610220527f21646563696d616c7300000000000000000000000000000000000000000000006102405261022050610220518061024001601f826000031636823750506308c379a06101e052602061020052601f19601f6102205101166044016101fcfd5b60605180600355600081601f0160051c60028111613bd857801561034e57905b8060051b608001518160040155600101818118610336575b50505060c0518060065560e051600755506102005160085562093a8060c435101561037a576000610385565b63095f6a0060c43511155b6103ef576008610220527f216d61786c6f636b0000000000000000000000000000000000000000000000006102405261022050610220518061024001601f826000031636823750506308c379a06101e052602061020052601f19601f6102205101166044016101fcfd5b60c435600155610120516c050c783eb9b5c840000000001255610140516c050c783eb9b5c840000000001355610160516c050c783eb9b5c840000000001c55610180516c050c783eb9b5c840000000001b556101a0516c050c783eb9b5c840000000001d556101c0516c050c783eb9b5c840000000001e556101e0516c050c783eb9b5c840000000001f55005b63fc0c546a811861049b5760043610613bd85760025460405260206040f35b6306fdde0381186105205760043610613bd8576020806040528060400160035480825260208201600082601f0160051c60028111613bd85780156104f257905b80600401548160051b8401526001018181186104db575b505050508051806020830101601f82600003163682375050601f19601f825160200101169050810190506040f35b6395d89b4181186105785760043610613bd8576020806040528060400160065480825260208201600754815250508051806020830101601f82600003163682375050601f19601f825160200101169050810190506040f35b63313ce56781186105975760043610613bd85760085460405260206040f35b636b441a40811861060d5760243610613bd8576004358060a01c613bd8576040526c050c783eb9b5c8400000000011543318613bd8576040516c050c783eb9b5c8400000000014557f2f56810a6bf40af059b96d3aea4db54081f378029a518390491093a7b67032e960405160605260206060a1005b636a1c05ae811861068f5760043610613bd8576c050c783eb9b5c8400000000011543318613bd8576c050c783eb9b5c84000000000145460405260405115613bd8576040516c050c783eb9b5c8400000000011557febee2d5739011062cb4f14113f3b36bf0ffe3da5c0568f64189d1012a118910560405160605260206060a1005b6357f901e281186106d95760243610613bd8576004358060a01c613bd8576040526c050c783eb9b5c8400000000011543318613bd8576040516c050c783eb9b5c840000000000f55005b638e5b490f81186107215760043610613bd8576c050c783eb9b5c8400000000011543318613bd8576c050c783eb9b5c840000000000f546c050c783eb9b5c840000000001055005b6355a3323581186108695760243610613bd8576004358060011c613bd8576040526c050c783eb9b5c8400000000013543318156107b55760066060527f2161646d696e000000000000000000000000000000000000000000000000000060805260605060605180608001601f826000031636823750506308c379a06020526020604052601f19601f6060510116604401603cfd5b6c050c783eb9b5c840000000001654604051186108295760076060527f616c72656164790000000000000000000000000000000000000000000000000060805260605060605180608001601f826000031636823750506308c379a06020526020604052601f19601f6060510116604401603cfd5b6040516c050c783eb9b5c8400000000016557f66515f71c349ef0ad8c6981cedaa58746200512e6e12754c5ac5cc701d5cf41860405160605260206060a1005b63655317ae8118610a445760243610613bd8576c050c783eb9b5c8400000000013543318156108ef5760066040527f2161646d696e000000000000000000000000000000000000000000000000000060605260405060405180606001601f826000031636823750506308c379a06000526020602052601f19601f6040510116604401601cfd5b603260043511156109575760026040527f216b00000000000000000000000000000000000000000000000000000000000060605260405060405180606001601f826000031636823750506308c379a06000526020602052601f19601f6040510116604401601cfd5b6c050c783eb9b5c840000000001954603c8101818110613bd857905042116109d65760056040527f6561726c7900000000000000000000000000000000000000000000000000000060605260405060405180606001601f826000031636823750506308c379a06000526020602052601f19601f6040510116604401601cfd5b6c050c783eb9b5c8400000000017546c050c783eb9b5c8400000000018556004356c050c783eb9b5c840000000001755426c050c783eb9b5c8400000000019557f9c04360e3c3de1eeca25bbd4a21cdc22c4c192f4b91f91d2a0c59dcd042f8ba860043560405260206040a1005b63af3097af8118610b7c5760243610613bd8576004358060a01c613bd8576040526c050c783eb9b5c840000000001354331815610ad85760066060527f2161646d696e000000000000000000000000000000000000000000000000000060805260605060605180608001601f826000031636823750506308c379a06020526020604052601f19601f6060510116604401603cfd5b604051610b3c5760056060527f217a65726f00000000000000000000000000000000000000000000000000000060805260605060605180608001601f826000031636823750506308c379a06020526020604052601f19601f6060510116604401603cfd5b6040516c050c783eb9b5c840000000001a557ff51c492eafc918620c2d49b196e6a4e0cac71709d348224a8e7fc231ee973e5960405160605260206060a1005b6366e01ca98118610c405760043610613bd8576c050c783eb9b5c840000000001254331815610c025760066040527f2161646d696e000000000000000000000000000000000000000000000000000060605260405060405180606001601f826000031636823750506308c379a06000526020602052601f19601f6040510116604401601cfd5b60016c050c783eb9b5c8400000000020557f7ca88488f569b55d1fb073429f75839e505182c1f6d26e5caed22e9828df430c600160405260206040a1005b637c74a1748118610cc25760243610613bd8576004358060a01c613bd8576040526c050c783eb9b5c840000000000d6040516020526000526040600020546060526c050c783eb9b5c840000000000c6040516020526000526040600020606051633b9ac9ff8111613bd85760021b810190506001810190505460805260206080f35b63da020a188118610d245760443610613bd8576004358060a01c613bd8576040526c050c783eb9b5c840000000000c6040516020526000526040600020602435633b9ac9ff8111613bd85760021b810190506002810190505460605260206060f35b63adc635898118610d655760243610613bd8576004358060a01c613bd857604052600a60405160205260005260406000206001810190505460605260206060f35b63c2c4c5c18118610d885760043610613bd85760a036604037610d86612e17565b005b633a46273e8118610f345760443610613bd8576004358060a01c613bd8576105e052600054600214613bd8576002600055600a6105e05160205260005260406000208054610600526001810154610620525060243515613bd8576001610600511215610e54576016610640527f4e6f206578697374696e67206c6f636b20666f756e64000000000000000000006106605261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b426106205111610ee9576024610640527f43616e6e6f742061646420746f2065787069726564206c6f636b2e2057697468610660527f64726177000000000000000000000000000000000000000000000000000000006106805261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b6105e0516103e05260243561040052600061042052600a6105e051602052600052604060002080546104405260018101546104605250600061048052610f2d61352a565b6003600055005b6365fc3873811861116a5760443610613bd857600054600214613bd857600260005533604052610f62612d1f565b60243562093a808104905062093a8081028162093a80820418613bd85790506105e052600a3360205260005260406000208054610600526001810154610620525060043515613bd857610600511561101a576019610640527f5769746864726177206f6c6420746f6b656e73206669727374000000000000006106605261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b426105e051116110af576026610640527f43616e206f6e6c79206c6f636b20756e74696c2074696d6520696e2074686520610660527f66757475726500000000000000000000000000000000000000000000000000006106805261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b42600154808201828110613bd857905090506105e0511115611131576014610640527f566f74696e67206c6f636b20746f6f206c6f6e670000000000000000000000006106605261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b336103e052600435610400526105e051610420526106005161044052610620516104605260016104805261116361352a565b6003600055005b634957677c81186112fc5760243610613bd857600054600214613bd857600260005533604052611198612d1f565b600a33602052600052604060002080546105e0526001810154610600525060043515613bd85760016105e0511215611230576016610620527f4e6f206578697374696e67206c6f636b20666f756e64000000000000000000006106405261062050610620518061064001601f826000031636823750506308c379a06105e052602061060052601f19601f6106205101166044016105fcfd5b4261060051116112c5576024610620527f43616e6e6f742061646420746f2065787069726564206c6f636b2e2057697468610640527f64726177000000000000000000000000000000000000000000000000000000006106605261062050610620518061064001601f826000031636823750506308c379a06105e052602061060052601f19601f6106205101166044016105fcfd5b336103e052600435610400526000610420526105e0516104405261060051610460526002610480526112f561352a565b6003600055005b63eff7a612811861157a5760243610613bd857600054600214613bd85760026000553360405261132a612d1f565b600a33602052600052604060002080546105e0526001810154610600525060043562093a808104905062093a8081028162093a80820418613bd8579050610620524261060051116113db57600c610640527f4c6f636b206578706972656400000000000000000000000000000000000000006106605261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b60016105e051121561144d576011610640527f4e6f7468696e67206973206c6f636b65640000000000000000000000000000006106605261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b6106005161062051116114c057601f610640527f43616e206f6e6c7920696e637265617365206c6f636b206475726174696f6e006106605261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b42600154808201828110613bd85790509050610620511115611542576014610640527f566f74696e67206c6f636b20746f6f206c6f6e670000000000000000000000006106605261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b336103e05260006104005261062051610420526105e05161044052610600516104605260036104805261157361352a565b6003600055005b633ccfd60b81186117cf5760043610613bd857600054600214613bd8576002600055600a33602052600052604060002080546103e05260018101546104005250610400514210156115d9576c050c783eb9b5c8400000000020546115dc565b60015b611646576017610420527f6c6f636b2021657870697265206f722021756e6c6f636b0000000000000000006104405261042050610420518061044001601f826000031636823750506308c379a06103e052602061040052601f19601f6104205101166044016103fcfd5b6103e05160008112613bd857610420526103e05161044052610400516104605260006104005260006103e052600a3360205260005260406000206103e051815561040051600182015550600954610480526104805161042051808203828111613bd857905090506009553360405261044051606052610460516080526103e05160a0526104005160c0526116d8612e17565b60025463a9059cbb6104a052336104c052610420516104e05260206104a060446104bc6000855af161170f573d600060003e3d6000fd5b3d61172657803b15613bd85760016105005261173f565b60203d10613bd8576104a0518060011c613bd857610500525b61050090505115613bd857337ff279e6a1f5e320cca91135676d9cb6e44ca8a08c0b88342bcdb1144f6511b568610420516104a052426104c05260406104a0a27f5e2aa66efd74cce82b21852e317e5490d9ecc9e6bb953ae24d90851258cc2f5c610480516104a0526104805161042051808203828111613bd857905090506104c05260406104a0a16003600055005b638239f0648118611c6e5760043610613bd857600054600214613bd857600260005560016c050c783eb9b5c840000000001654181561186e57600d6103e0527f216561726c7920756e6c6f636b00000000000000000000000000000000000000610400526103e0506103e0518061040001601f826000031636823750506308c379a06103a05260206103c052601f19601f6103e05101166044016103bcfd5b600a33602052600052604060002080546103e052600181015461040052506104005142106118fc57600c610420527f6c6f636b206578706972656400000000000000000000000000000000000000006104405261042050610420518061044001601f826000031636823750506308c379a06103e052602061040052601f19601f6104205101166044016103fcfd5b6103e05160008112613bd857610420526104005142808203828111613bd85790509050610440526000610460526c050c783eb9b5c840000000001954603c8101818110613bd85790504211611963576c050c783eb9b5c84000000000185461046052611977565b6c050c783eb9b5c840000000001754610460525b61044051670de0b6b3a7640000810281670de0b6b3a7640000820418613bd85790506001548015613bd8578082049050905061046051808202811583838304141715613bd85790509050610480526104205161048051808202811583838304141715613bd85790509050670de0b6b3a764000081049050600a810490506104a052610420516104a0511115611a0f57610420516104a0525b610420516104a051808203828111613bd857905090506104c0526103e0516104e052610400516105005260006104005260006103e052600a3360205260005260406000206103e051815561040051600182015550600954610520526105205161042051808203828111613bd85790509050600955336040526104e051606052610500516080526103e05160a0526104005160c052611aab612e17565b6104a05115611b355760025463a9059cbb610540526c050c783eb9b5c840000000001a54610560526104a051610580526020610540604461055c6000855af1611af9573d600060003e3d6000fd5b3d611b1057803b15613bd85760016105a052611b29565b60203d10613bd857610540518060011c613bd8576105a0525b6105a090505115613bd8575b6104c05115611bb15760025463a9059cbb6105405233610560526104c051610580526020610540604461055c6000855af1611b75573d600060003e3d6000fd5b3d611b8c57803b15613bd85760016105a052611ba5565b60203d10613bd857610540518060011c613bd8576105a0525b6105a090505115613bd8575b337ff279e6a1f5e320cca91135676d9cb6e44ca8a08c0b88342bcdb1144f6511b568610420516105405242610560526040610540a27f5e2aa66efd74cce82b21852e317e5490d9ecc9e6bb953ae24d90851258cc2f5c61052051610540526105205161042051808203828111613bd85790509050610560526040610540a1337ff11a1a5a36ad286d4f77c166e8d87e5bfe87d1e74f9c46654ef21bd811c6784f6104a0516105405261044051610560526040610540a26003600055005b6370a082318118611c8b5760243610613bd8574261014052611ca5565b62fdd58e8118611e345760443610613bd857602435610140525b6004358060a01c613bd85761012052600061016052426101405118611ceb576c050c783eb9b5c840000000000d6101205160205260005260406000205461016052611d2e565b61012051604052610140516060526c050c783eb9b5c840000000000d61012051602052600052604060002054608052611d256101806139a2565b61018051610160525b61016051611d4a576000610180526020610180611e3256611e32565b6c050c783eb9b5c840000000000c61012051602052600052604060002061016051633b9ac9ff8111613bd85760021b8101905080546101805260018101546101a05260028101546101c05260038101546101e05250610180516101a051610140516101c051808203828111613bd8579050905080607f1c613bd85780820280600f0b8118613bd8579050905080820380600f0b8118613bd85790509050610180527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff6101805113611e1c576000610180525b6101805160008112613bd8576102005260206102005bf35b634ee2cd7e811861210f5760443610613bd8576004358060a01c613bd857610120524360243511613bd857610120516040526024356060526c050c783eb9b5c840000000000d61012051602052600052604060002054608052611e986101606138da565b61016051610140526c050c783eb9b5c840000000000c61012051602052600052604060002061014051633b9ac9ff8111613bd85760021b8101905080546101605260018101546101805260028101546101a05260038101546101c05250600b546101e0526024356040526101e051606052611f1461022061377a565b6102205161020052610200516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c01805461022052600181015461024052600281015461026052600381015461028052506040366102a0376101e0516102005110611fa3574361028051808203828111613bd857905090506102a0524261026051808203828111613bd857905090506102c052612025565b6102005160018101818110613bd85790506c01431e0fae6d7217ca9fffffff8111613bd85760021b600c0180546102e052600181015461030052600281015461032052600381015461034052506103405161028051808203828111613bd857905090506102a0526103205161026051808203828111613bd857905090506102c0525b610260516102e0526102a0511561208b576102e0516102c05160243561028051808203828111613bd85790509050808202811583838304141715613bd857905090506102a0518015613bd85780820490509050808201828110613bd857905090506102e0525b61016051610180516102e0516101a051808203828111613bd8579050905080607f1c613bd85780820280600f0b8118613bd8579050905080820380600f0b8118613bd857905090506101605260006101605112156120f757600061030052602061030061210d5661210d565b6101605160008112613bd8576103005260206103005bf35b6318160ddd811861212c5760043610613bd857426101c052612147565b63bd85b03981186122165760243610613bd8576004356101c0525b60006101e052426101c0511861216357600b546101e052612184565b6101c051604052600b5460605261217b61020061382a565b610200516101e0525b6101e0516121a057600061020052602061020061221456612214565b6101e0516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c018054610200526001810154610220526002810154610240526003810154610260525060206102005160405261022051606052610240516080526102605160a0526101c05160c052612210610280613a6a565b6102805bf35b63981b24d081186124235760243610613bd8574360043511613bd857600b546101c0526004356040526101c05160605261225161020061377a565b610200516101e0526101e0516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c01805461020052600181015461022052600281015461024052600381015461026052506000610280526101c0516101e0511061231b574361026051146123dc5760043561026051808203828111613bd857905090504261024051808203828111613bd85790509050808202811583838304141715613bd857905090504361026051808203828111613bd857905090508015613bd85780820490509050610280526123dc565b6101e05160018101818110613bd85790506c01431e0fae6d7217ca9fffffff8111613bd85760021b600c0180546102a05260018101546102c05260028101546102e052600381015461030052506103005161026051146123dc5760043561026051808203828111613bd857905090506102e05161024051808203828111613bd85790509050808202811583838304141715613bd857905090506103005161026051808203828111613bd857905090508015613bd85780820490509050610280525b60206102005160405261022051606052610240516080526102605160a0526102405161028051808201828110613bd8579050905060c05261241e6102a0613a6a565b6102a0f35b63db93cc7a811861265e5760043610613bd857600054600214613bd85760026000556c050c783eb9b5c840000000001b54636a627842604052600254606052602060406024605c6000855af161247e573d600060003e3d6000fd5b60203d10613bd857604050506c050c783eb9b5c840000000001c546370a0823160605230608052602060606024607c845afa6124bf573d600060003e3d6000fd5b60203d10613bd857606090505160405260405115612657576c050c783eb9b5c840000000001f546c050c783eb9b5c840000000001d54186125d4576c050c783eb9b5c840000000001c5463095ea7b36060526c050c783eb9b5c840000000001f5460805260405160a052602060606044607c6000855af1612545573d600060003e3d6000fd5b3d61255b57803b15613bd857600160c052612572565b60203d10613bd8576060518060011c613bd85760c0525b60c090505115613bd8576c050c783eb9b5c840000000001f5463338b5dea6060526c050c783eb9b5c840000000001c5460805260405160a052803b15613bd857600060606044607c6000855af16125ce573d600060003e3d6000fd5b50612657565b6c050c783eb9b5c840000000001c5463a9059cbb6060526c050c783eb9b5c840000000001d5460805260405160a052602060606044607c6000855af161261f573d600060003e3d6000fd5b3d61263557803b15613bd857600160c05261264c565b60203d10613bd8576060518060011c613bd85760c0525b60c090505115613bd8575b6003600055005b63cb8e090d81186128065760243610613bd8576004358060a01c613bd8576040526c050c783eb9b5c8400000000011543318156126f25760066060527f2161646d696e000000000000000000000000000000000000000000000000000060805260605060605180608001601f826000031636823750506308c379a06020526020604052601f19601f6060510116604401603cfd5b6c050c783eb9b5c840000000001e5461276257600a6060527f21617661696c61626c650000000000000000000000000000000000000000000060805260605060605180608001601f826000031636823750506308c379a06020526020604052601f19601f6060510116604401603cfd5b6040516127c65760066060527f21656d707479000000000000000000000000000000000000000000000000000060805260605060605180608001601f826000031636823750506308c379a06020526020604052601f19601f6060510116604401603cfd5b6040516c050c783eb9b5c840000000001d557f454ccf21cdac2e344d64173597f5922657c25c5b1e6c3f733791637513d2063760405160605260206060a1005b63ee00ef3a81186128255760043610613bd85760015460405260206040f35b6382bfefc881186128445760043610613bd85760025460405260206040f35b63047fc9aa81186128635760043610613bd85760095460405260206040f35b63cbf9fe5f81186128a85760243610613bd8576004358060a01c613bd857604052600a6040516020526000526040600020805460605260018101546080525060406060f35b63900cf0cf81186128c75760043610613bd857600b5460405260206040f35b63d1febfb9811861291b5760243610613bd8576004356c01431e0fae6d7217ca9fffffff8111613bd85760021b600c01805460405260018101546060526002810154608052600381015460a0525060806040f35b6328d09d4781186129915760443610613bd8576004358060a01c613bd8576040526c050c783eb9b5c840000000000c6040516020526000526040600020602435633b9ac9ff8111613bd85760021b8101905080546060526001810154608052600281015460a052600381015460c0525060806060f35b63010ae75781186129d85760243610613bd8576004358060a01c613bd8576040526c050c783eb9b5c840000000000d60405160205260005260406000205460605260206060f35b63711974848118612a115760243610613bd8576c050c783eb9b5c840000000000e60043560205260005260406000205460405260206040f35b638ff36fd18118612a3c5760043610613bd8576c050c783eb9b5c840000000000f5460405260206040f35b637175d4f78118612a675760043610613bd8576c050c783eb9b5c84000000000105460405260206040f35b63f851a4408118612a925760043610613bd8576c050c783eb9b5c84000000000115460405260206040f35b63142614258118612abd5760043610613bd8576c050c783eb9b5c84000000000125460405260206040f35b6322cf35f58118612ae85760043610613bd8576c050c783eb9b5c84000000000135460405260206040f35b6317f7182a8118612b135760043610613bd8576c050c783eb9b5c84000000000145460405260206040f35b639a01873c8118612b3e5760043610613bd8576c050c783eb9b5c84000000000155460405260206040f35b63f68467278118612b695760043610613bd8576c050c783eb9b5c84000000000165460405260206040f35b63cd8c79aa8118612b945760043610613bd8576c050c783eb9b5c84000000000175460405260206040f35b63094cda238118612bbf5760043610613bd8576c050c783eb9b5c84000000000185460405260206040f35b63205ad4088118612bea5760043610613bd8576c050c783eb9b5c84000000000195460405260206040f35b635836ec3a8118612c155760043610613bd8576c050c783eb9b5c840000000001a5460405260206040f35b6373f43d6d8118612c405760043610613bd8576c050c783eb9b5c840000000001b5460405260206040f35b6338d546458118612c6b5760043610613bd8576c050c783eb9b5c840000000001c5460405260206040f35b631dac30b08118612c965760043610613bd8576c050c783eb9b5c840000000001d5460405260206040f35b63b027651a8118612cc15760043610613bd8576c050c783eb9b5c840000000001e5460405260206040f35b63acc2166a8118612cec5760043610613bd8576c050c783eb9b5c840000000001f5460405260206040f35b63b3d8f7e78118612d175760043610613bd8576c050c783eb9b5c84000000000205460405260206040f35b505b60006000fd5b3260405114612e15576c050c783eb9b5c84000000000105460605260605115612d945760605163c23697a860805260405160a052602060806024609c6000855af1612d6f573d600060003e3d6000fd5b60203d10613bd8576080518060011c613bd85760c05260c090505115612d9457612e15565b60256080527f536d61727420636f6e7472616374206465706f7369746f7273206e6f7420616c60a0527f6c6f77656400000000000000000000000000000000000000000000000000000060c0526080506080518060a001601f826000031636823750506308c379a06040526020606052601f19601f6080510116604401605cfd5b565b6101403660e037600b546102205260405115612f83574260805111612e3d576000612e45565b600160605112155b15612ea65760605160015480607f1c613bd8578015613bd85780820580600f0b8118613bd85790509050610100526101005160805142808203828111613bd8579050905080607f1c613bd85780820280600f0b8118613bd8579050905060e0525b4260c05111612eb6576000612ebe565b600160a05112155b15612f205760a05160015480607f1c613bd8578015613bd85780820580600f0b8118613bd85790509050610180526101805160c05142808203828111613bd8579050905080607f1c613bd85780820280600f0b8118613bd85790509050610160525b6c050c783eb9b5c840000000000e6080516020526000526040600020546101e05260c05115612f835760805160c05118612f61576101e05161020052612f83565b6c050c783eb9b5c840000000000e60c051602052600052604060002054610200525b604036610240374261028052436102a0526102205115612fde57610220516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c0180546102405260018101546102605260028101546102805260038101546102a052505b610280516102c052610240516102e052610260516103005261028051610320526102a051610340526000610360526102805142111561306d57436102a051808203828111613bd85790509050670de0b6b3a7640000810281670de0b6b3a7640000820418613bd85790504261028051808203828111613bd857905090508015613bd85780820490509050610360525b6102c05162093a808104905062093a8081028162093a80820418613bd857905061038052600060ff905b806103a0526103805162093a808101818110613bd85790506103805260006103c0524261038051116130ea576c050c783eb9b5c840000000000e610380516020526000526040600020546103c0526130f0565b42610380525b6102405161026051610380516102c051808203828111613bd8579050905080607f1c613bd85780820280600f0b8118613bd8579050905080820380600f0b8118613bd8579050905061024052610260516103c05180820180600f0b8118613bd85790509050610260527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff610240511361318a576000610240525b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff61026051136131bb576000610260525b610380516102c052610380516102805261034051610360516103805161032051808203828111613bd85790509050808202811583838304141715613bd85790509050670de0b6b3a764000081049050808201828110613bd857905090506102a0526102205160018101818110613bd85790506102205242610380511861324957436102a0526132955661328a565b610220516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c016102405181556102605160018201556102805160028201556102a0516003820155505b600101818118613097575b505061022051600b556040511561336b5761026051610180516101005180820380600f0b8118613bd8579050905080820180600f0b8118613bd8579050905061026052610240516101605160e05180820380600f0b8118613bd8579050905080820180600f0b8118613bd85790509050610240527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff610260511361333a576000610260525b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff610240511361336b576000610240525b610220516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c016102405181556102605160018201556102805160028201556102a0516003820155506040511561352857426080511115613425576101e0516101005180820180600f0b8118613bd857905090506101e05260805160c05118613403576101e0516101805180820380600f0b8118613bd857905090506101e0525b6101e0516c050c783eb9b5c840000000000e6080516020526000526040600020555b4260c051111561347a5760805160c051111561347a57610200516101805180820380600f0b8118613bd8579050905061020052610200516c050c783eb9b5c840000000000e60c0516020526000526040600020555b6c050c783eb9b5c840000000000d60405160205260005260406000205460018101818110613bd85790506103a0526103a0516c050c783eb9b5c840000000000d604051602052600052604060002055426101a052436101c0526c050c783eb9b5c840000000000c60405160205260005260406000206103a051633b9ac9ff8111613bd85760021b810190506101605181556101805160018201556101a05160028201556101c0516003820155505b565b6c050c783eb9b5c840000000002054156135a45760156104a0527f616c6c20756e6c6f636b65642c6e6f2073656e736500000000000000000000006104c0526104a0506104a051806104c001601f826000031636823750506308c379a061046052602061048052601f19601f6104a051011660440161047cfd5b610440516104a052610460516104c0526009546104e0526104e05161040051808201828110613bd857905090506009556104a051610500526104c051610520526104a0516104005180607f1c613bd85780820180600f0b8118613bd857905090506104a052610420511561361b57610420516104c0525b600a6103e05160205260005260406000206104a05181556104c0516001820155506103e05160405261050051606052610520516080526104a05160a0526104c05160c052613667612e17565b61040051156136eb576002546323b872dd610540526103e051610560523061058052610400516105a0526020610540606461055c6000855af16136af573d600060003e3d6000fd5b3d6136c657803b15613bd85760016105c0526136df565b60203d10613bd857610540518060011c613bd8576105c0525b6105c090505115613bd8575b6104c0516103e0517f4566dfc29f6f11d13a418c26a02bef7c28bae749d4de47e4e6a7cddea6730d596104005161054052610480516105605242610580526060610540a37f5e2aa66efd74cce82b21852e317e5490d9ecc9e6bb953ae24d90851258cc2f5c6104e051610540526104e05161040051808201828110613bd85790509050610560526040610540a1565b600060805260605160a05260006080905b8060c05260a0516080511061379f57613820565b60805160a051808201828110613bd8579050905060018101818110613bd85790508060011c905060e05260405160e0516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c0160038101905054111561380e5760e05160018103818111613bd857905060a052613815565b60e0516080525b60010181811861378b575b5050608051815250565b600060805260605160a05260006080905b8060c05260a0516080511061384f576138d0565b60805160a051808201828110613bd8579050905060018101818110613bd85790508060011c905060e05260405160e0516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c016002810190505411156138be5760e05160018103818111613bd857905060a0526138c5565b60e0516080525b60010181811861383b575b5050608051815250565b600060a05260805160c05260006080905b8060e05260c05160a051106138ff57613998565b60a05160c051808201828110613bd8579050905060018101818110613bd85790508060011c9050610100526060516c050c783eb9b5c840000000000c604051602052600052604060002061010051633b9ac9ff8111613bd85760021b81019050600381019050541115613985576101005160018103818111613bd857905060c05261398d565b6101005160a0525b6001018181186138eb575b505060a051815250565b600060a05260805160c05260006080905b8060e05260c05160a051106139c757613a60565b60a05160c051808201828110613bd8579050905060018101818110613bd85790508060011c9050610100526060516c050c783eb9b5c840000000000c604051602052600052604060002061010051633b9ac9ff8111613bd85760021b81019050600281019050541115613a4d576101005160018103818111613bd857905060c052613a55565b6101005160a0525b6001018181186139b3575b505060a051815250565b60405160e052606051610100526080516101205260a051610140526101205162093a808104905062093a8081028162093a80820418613bd857905061016052600060ff905b80610180526101605162093a808101818110613bd85790506101605260006101a05260c0516101605111613b04576c050c783eb9b5c840000000000e610160516020526000526040600020546101a052613b0c565b60c051610160525b60e051610100516101605161012051808203828111613bd8579050905080607f1c613bd85780820280600f0b8118613bd8579050905080820380600f0b8118613bd8579050905060e05260c0516101605118613b6757613b97565b610100516101a05180820180600f0b8118613bd85790509050610100526101605161012052600101818118613aaf575b50507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff60e05113613bc857600060e0525b60e05160008112613bd857815250565b600080fda165767970657283000307000b", + "deployedBytecode": "0x6003361161000c57612d19565b60003560e01c34613bd857639bf6abf3811861047c576101c43610613bd8576004358060a01c613bd8576040526024356004016040813511613bd8578035806060526020820181816080375050506044356004016020813511613bd85780358060c05260208201803560e0525050506064358060a01c613bd857610100526084358060a01c613bd8576101205260a4358060a01c613bd8576101405260e4358060a01c613bd85761016052610104358060a01c613bd85761018052610124358060a01c613bd8576101a052610144358060011c613bd8576101c052610164358060a01c613bd8576101e0526c050c783eb9b5c84000000000155415610171576009610200527f6f6e6c79206f6e636500000000000000000000000000000000000000000000006102205261020050610200518061022001601f826000031636823750506308c379a06101c05260206101e052601f19601f6102005101166044016101dcfd5b60016c050c783eb9b5c840000000001555610100516101f0576006610200527f21656d70747900000000000000000000000000000000000000000000000000006102205261020050610200518061022001601f826000031636823750506308c379a06101c05260206101e052601f19601f6102005101166044016101dcfd5b610100516c050c783eb9b5c840000000001155600a6c050c783eb9b5c840000000001755600a6c050c783eb9b5c840000000001855426c050c783eb9b5c840000000001955610100516c050c783eb9b5c840000000001a5560405160025543600f5542600e5560405163313ce567610220526020610220600461023c845afa61027e573d600060003e3d6000fd5b60203d10613bd8576102209050516102005260066102005110156102a35760006102ac565b60ff6102005111155b610316576009610220527f21646563696d616c7300000000000000000000000000000000000000000000006102405261022050610220518061024001601f826000031636823750506308c379a06101e052602061020052601f19601f6102205101166044016101fcfd5b60605180600355600081601f0160051c60028111613bd857801561034e57905b8060051b608001518160040155600101818118610336575b50505060c0518060065560e051600755506102005160085562093a8060c435101561037a576000610385565b63095f6a0060c43511155b6103ef576008610220527f216d61786c6f636b0000000000000000000000000000000000000000000000006102405261022050610220518061024001601f826000031636823750506308c379a06101e052602061020052601f19601f6102205101166044016101fcfd5b60c435600155610120516c050c783eb9b5c840000000001255610140516c050c783eb9b5c840000000001355610160516c050c783eb9b5c840000000001c55610180516c050c783eb9b5c840000000001b556101a0516c050c783eb9b5c840000000001d556101c0516c050c783eb9b5c840000000001e556101e0516c050c783eb9b5c840000000001f55005b63fc0c546a811861049b5760043610613bd85760025460405260206040f35b6306fdde0381186105205760043610613bd8576020806040528060400160035480825260208201600082601f0160051c60028111613bd85780156104f257905b80600401548160051b8401526001018181186104db575b505050508051806020830101601f82600003163682375050601f19601f825160200101169050810190506040f35b6395d89b4181186105785760043610613bd8576020806040528060400160065480825260208201600754815250508051806020830101601f82600003163682375050601f19601f825160200101169050810190506040f35b63313ce56781186105975760043610613bd85760085460405260206040f35b636b441a40811861060d5760243610613bd8576004358060a01c613bd8576040526c050c783eb9b5c8400000000011543318613bd8576040516c050c783eb9b5c8400000000014557f2f56810a6bf40af059b96d3aea4db54081f378029a518390491093a7b67032e960405160605260206060a1005b636a1c05ae811861068f5760043610613bd8576c050c783eb9b5c8400000000011543318613bd8576c050c783eb9b5c84000000000145460405260405115613bd8576040516c050c783eb9b5c8400000000011557febee2d5739011062cb4f14113f3b36bf0ffe3da5c0568f64189d1012a118910560405160605260206060a1005b6357f901e281186106d95760243610613bd8576004358060a01c613bd8576040526c050c783eb9b5c8400000000011543318613bd8576040516c050c783eb9b5c840000000000f55005b638e5b490f81186107215760043610613bd8576c050c783eb9b5c8400000000011543318613bd8576c050c783eb9b5c840000000000f546c050c783eb9b5c840000000001055005b6355a3323581186108695760243610613bd8576004358060011c613bd8576040526c050c783eb9b5c8400000000013543318156107b55760066060527f2161646d696e000000000000000000000000000000000000000000000000000060805260605060605180608001601f826000031636823750506308c379a06020526020604052601f19601f6060510116604401603cfd5b6c050c783eb9b5c840000000001654604051186108295760076060527f616c72656164790000000000000000000000000000000000000000000000000060805260605060605180608001601f826000031636823750506308c379a06020526020604052601f19601f6060510116604401603cfd5b6040516c050c783eb9b5c8400000000016557f66515f71c349ef0ad8c6981cedaa58746200512e6e12754c5ac5cc701d5cf41860405160605260206060a1005b63655317ae8118610a445760243610613bd8576c050c783eb9b5c8400000000013543318156108ef5760066040527f2161646d696e000000000000000000000000000000000000000000000000000060605260405060405180606001601f826000031636823750506308c379a06000526020602052601f19601f6040510116604401601cfd5b603260043511156109575760026040527f216b00000000000000000000000000000000000000000000000000000000000060605260405060405180606001601f826000031636823750506308c379a06000526020602052601f19601f6040510116604401601cfd5b6c050c783eb9b5c840000000001954603c8101818110613bd857905042116109d65760056040527f6561726c7900000000000000000000000000000000000000000000000000000060605260405060405180606001601f826000031636823750506308c379a06000526020602052601f19601f6040510116604401601cfd5b6c050c783eb9b5c8400000000017546c050c783eb9b5c8400000000018556004356c050c783eb9b5c840000000001755426c050c783eb9b5c8400000000019557f9c04360e3c3de1eeca25bbd4a21cdc22c4c192f4b91f91d2a0c59dcd042f8ba860043560405260206040a1005b63af3097af8118610b7c5760243610613bd8576004358060a01c613bd8576040526c050c783eb9b5c840000000001354331815610ad85760066060527f2161646d696e000000000000000000000000000000000000000000000000000060805260605060605180608001601f826000031636823750506308c379a06020526020604052601f19601f6060510116604401603cfd5b604051610b3c5760056060527f217a65726f00000000000000000000000000000000000000000000000000000060805260605060605180608001601f826000031636823750506308c379a06020526020604052601f19601f6060510116604401603cfd5b6040516c050c783eb9b5c840000000001a557ff51c492eafc918620c2d49b196e6a4e0cac71709d348224a8e7fc231ee973e5960405160605260206060a1005b6366e01ca98118610c405760043610613bd8576c050c783eb9b5c840000000001254331815610c025760066040527f2161646d696e000000000000000000000000000000000000000000000000000060605260405060405180606001601f826000031636823750506308c379a06000526020602052601f19601f6040510116604401601cfd5b60016c050c783eb9b5c8400000000020557f7ca88488f569b55d1fb073429f75839e505182c1f6d26e5caed22e9828df430c600160405260206040a1005b637c74a1748118610cc25760243610613bd8576004358060a01c613bd8576040526c050c783eb9b5c840000000000d6040516020526000526040600020546060526c050c783eb9b5c840000000000c6040516020526000526040600020606051633b9ac9ff8111613bd85760021b810190506001810190505460805260206080f35b63da020a188118610d245760443610613bd8576004358060a01c613bd8576040526c050c783eb9b5c840000000000c6040516020526000526040600020602435633b9ac9ff8111613bd85760021b810190506002810190505460605260206060f35b63adc635898118610d655760243610613bd8576004358060a01c613bd857604052600a60405160205260005260406000206001810190505460605260206060f35b63c2c4c5c18118610d885760043610613bd85760a036604037610d86612e17565b005b633a46273e8118610f345760443610613bd8576004358060a01c613bd8576105e052600054600214613bd8576002600055600a6105e05160205260005260406000208054610600526001810154610620525060243515613bd8576001610600511215610e54576016610640527f4e6f206578697374696e67206c6f636b20666f756e64000000000000000000006106605261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b426106205111610ee9576024610640527f43616e6e6f742061646420746f2065787069726564206c6f636b2e2057697468610660527f64726177000000000000000000000000000000000000000000000000000000006106805261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b6105e0516103e05260243561040052600061042052600a6105e051602052600052604060002080546104405260018101546104605250600061048052610f2d61352a565b6003600055005b6365fc3873811861116a5760443610613bd857600054600214613bd857600260005533604052610f62612d1f565b60243562093a808104905062093a8081028162093a80820418613bd85790506105e052600a3360205260005260406000208054610600526001810154610620525060043515613bd857610600511561101a576019610640527f5769746864726177206f6c6420746f6b656e73206669727374000000000000006106605261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b426105e051116110af576026610640527f43616e206f6e6c79206c6f636b20756e74696c2074696d6520696e2074686520610660527f66757475726500000000000000000000000000000000000000000000000000006106805261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b42600154808201828110613bd857905090506105e0511115611131576014610640527f566f74696e67206c6f636b20746f6f206c6f6e670000000000000000000000006106605261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b336103e052600435610400526105e051610420526106005161044052610620516104605260016104805261116361352a565b6003600055005b634957677c81186112fc5760243610613bd857600054600214613bd857600260005533604052611198612d1f565b600a33602052600052604060002080546105e0526001810154610600525060043515613bd85760016105e0511215611230576016610620527f4e6f206578697374696e67206c6f636b20666f756e64000000000000000000006106405261062050610620518061064001601f826000031636823750506308c379a06105e052602061060052601f19601f6106205101166044016105fcfd5b4261060051116112c5576024610620527f43616e6e6f742061646420746f2065787069726564206c6f636b2e2057697468610640527f64726177000000000000000000000000000000000000000000000000000000006106605261062050610620518061064001601f826000031636823750506308c379a06105e052602061060052601f19601f6106205101166044016105fcfd5b336103e052600435610400526000610420526105e0516104405261060051610460526002610480526112f561352a565b6003600055005b63eff7a612811861157a5760243610613bd857600054600214613bd85760026000553360405261132a612d1f565b600a33602052600052604060002080546105e0526001810154610600525060043562093a808104905062093a8081028162093a80820418613bd8579050610620524261060051116113db57600c610640527f4c6f636b206578706972656400000000000000000000000000000000000000006106605261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b60016105e051121561144d576011610640527f4e6f7468696e67206973206c6f636b65640000000000000000000000000000006106605261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b6106005161062051116114c057601f610640527f43616e206f6e6c7920696e637265617365206c6f636b206475726174696f6e006106605261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b42600154808201828110613bd85790509050610620511115611542576014610640527f566f74696e67206c6f636b20746f6f206c6f6e670000000000000000000000006106605261064050610640518061066001601f826000031636823750506308c379a061060052602061062052601f19601f61064051011660440161061cfd5b336103e05260006104005261062051610420526105e05161044052610600516104605260036104805261157361352a565b6003600055005b633ccfd60b81186117cf5760043610613bd857600054600214613bd8576002600055600a33602052600052604060002080546103e05260018101546104005250610400514210156115d9576c050c783eb9b5c8400000000020546115dc565b60015b611646576017610420527f6c6f636b2021657870697265206f722021756e6c6f636b0000000000000000006104405261042050610420518061044001601f826000031636823750506308c379a06103e052602061040052601f19601f6104205101166044016103fcfd5b6103e05160008112613bd857610420526103e05161044052610400516104605260006104005260006103e052600a3360205260005260406000206103e051815561040051600182015550600954610480526104805161042051808203828111613bd857905090506009553360405261044051606052610460516080526103e05160a0526104005160c0526116d8612e17565b60025463a9059cbb6104a052336104c052610420516104e05260206104a060446104bc6000855af161170f573d600060003e3d6000fd5b3d61172657803b15613bd85760016105005261173f565b60203d10613bd8576104a0518060011c613bd857610500525b61050090505115613bd857337ff279e6a1f5e320cca91135676d9cb6e44ca8a08c0b88342bcdb1144f6511b568610420516104a052426104c05260406104a0a27f5e2aa66efd74cce82b21852e317e5490d9ecc9e6bb953ae24d90851258cc2f5c610480516104a0526104805161042051808203828111613bd857905090506104c05260406104a0a16003600055005b638239f0648118611c6e5760043610613bd857600054600214613bd857600260005560016c050c783eb9b5c840000000001654181561186e57600d6103e0527f216561726c7920756e6c6f636b00000000000000000000000000000000000000610400526103e0506103e0518061040001601f826000031636823750506308c379a06103a05260206103c052601f19601f6103e05101166044016103bcfd5b600a33602052600052604060002080546103e052600181015461040052506104005142106118fc57600c610420527f6c6f636b206578706972656400000000000000000000000000000000000000006104405261042050610420518061044001601f826000031636823750506308c379a06103e052602061040052601f19601f6104205101166044016103fcfd5b6103e05160008112613bd857610420526104005142808203828111613bd85790509050610440526000610460526c050c783eb9b5c840000000001954603c8101818110613bd85790504211611963576c050c783eb9b5c84000000000185461046052611977565b6c050c783eb9b5c840000000001754610460525b61044051670de0b6b3a7640000810281670de0b6b3a7640000820418613bd85790506001548015613bd8578082049050905061046051808202811583838304141715613bd85790509050610480526104205161048051808202811583838304141715613bd85790509050670de0b6b3a764000081049050600a810490506104a052610420516104a0511115611a0f57610420516104a0525b610420516104a051808203828111613bd857905090506104c0526103e0516104e052610400516105005260006104005260006103e052600a3360205260005260406000206103e051815561040051600182015550600954610520526105205161042051808203828111613bd85790509050600955336040526104e051606052610500516080526103e05160a0526104005160c052611aab612e17565b6104a05115611b355760025463a9059cbb610540526c050c783eb9b5c840000000001a54610560526104a051610580526020610540604461055c6000855af1611af9573d600060003e3d6000fd5b3d611b1057803b15613bd85760016105a052611b29565b60203d10613bd857610540518060011c613bd8576105a0525b6105a090505115613bd8575b6104c05115611bb15760025463a9059cbb6105405233610560526104c051610580526020610540604461055c6000855af1611b75573d600060003e3d6000fd5b3d611b8c57803b15613bd85760016105a052611ba5565b60203d10613bd857610540518060011c613bd8576105a0525b6105a090505115613bd8575b337ff279e6a1f5e320cca91135676d9cb6e44ca8a08c0b88342bcdb1144f6511b568610420516105405242610560526040610540a27f5e2aa66efd74cce82b21852e317e5490d9ecc9e6bb953ae24d90851258cc2f5c61052051610540526105205161042051808203828111613bd85790509050610560526040610540a1337ff11a1a5a36ad286d4f77c166e8d87e5bfe87d1e74f9c46654ef21bd811c6784f6104a0516105405261044051610560526040610540a26003600055005b6370a082318118611c8b5760243610613bd8574261014052611ca5565b62fdd58e8118611e345760443610613bd857602435610140525b6004358060a01c613bd85761012052600061016052426101405118611ceb576c050c783eb9b5c840000000000d6101205160205260005260406000205461016052611d2e565b61012051604052610140516060526c050c783eb9b5c840000000000d61012051602052600052604060002054608052611d256101806139a2565b61018051610160525b61016051611d4a576000610180526020610180611e3256611e32565b6c050c783eb9b5c840000000000c61012051602052600052604060002061016051633b9ac9ff8111613bd85760021b8101905080546101805260018101546101a05260028101546101c05260038101546101e05250610180516101a051610140516101c051808203828111613bd8579050905080607f1c613bd85780820280600f0b8118613bd8579050905080820380600f0b8118613bd85790509050610180527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff6101805113611e1c576000610180525b6101805160008112613bd8576102005260206102005bf35b634ee2cd7e811861210f5760443610613bd8576004358060a01c613bd857610120524360243511613bd857610120516040526024356060526c050c783eb9b5c840000000000d61012051602052600052604060002054608052611e986101606138da565b61016051610140526c050c783eb9b5c840000000000c61012051602052600052604060002061014051633b9ac9ff8111613bd85760021b8101905080546101605260018101546101805260028101546101a05260038101546101c05250600b546101e0526024356040526101e051606052611f1461022061377a565b6102205161020052610200516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c01805461022052600181015461024052600281015461026052600381015461028052506040366102a0376101e0516102005110611fa3574361028051808203828111613bd857905090506102a0524261026051808203828111613bd857905090506102c052612025565b6102005160018101818110613bd85790506c01431e0fae6d7217ca9fffffff8111613bd85760021b600c0180546102e052600181015461030052600281015461032052600381015461034052506103405161028051808203828111613bd857905090506102a0526103205161026051808203828111613bd857905090506102c0525b610260516102e0526102a0511561208b576102e0516102c05160243561028051808203828111613bd85790509050808202811583838304141715613bd857905090506102a0518015613bd85780820490509050808201828110613bd857905090506102e0525b61016051610180516102e0516101a051808203828111613bd8579050905080607f1c613bd85780820280600f0b8118613bd8579050905080820380600f0b8118613bd857905090506101605260006101605112156120f757600061030052602061030061210d5661210d565b6101605160008112613bd8576103005260206103005bf35b6318160ddd811861212c5760043610613bd857426101c052612147565b63bd85b03981186122165760243610613bd8576004356101c0525b60006101e052426101c0511861216357600b546101e052612184565b6101c051604052600b5460605261217b61020061382a565b610200516101e0525b6101e0516121a057600061020052602061020061221456612214565b6101e0516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c018054610200526001810154610220526002810154610240526003810154610260525060206102005160405261022051606052610240516080526102605160a0526101c05160c052612210610280613a6a565b6102805bf35b63981b24d081186124235760243610613bd8574360043511613bd857600b546101c0526004356040526101c05160605261225161020061377a565b610200516101e0526101e0516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c01805461020052600181015461022052600281015461024052600381015461026052506000610280526101c0516101e0511061231b574361026051146123dc5760043561026051808203828111613bd857905090504261024051808203828111613bd85790509050808202811583838304141715613bd857905090504361026051808203828111613bd857905090508015613bd85780820490509050610280526123dc565b6101e05160018101818110613bd85790506c01431e0fae6d7217ca9fffffff8111613bd85760021b600c0180546102a05260018101546102c05260028101546102e052600381015461030052506103005161026051146123dc5760043561026051808203828111613bd857905090506102e05161024051808203828111613bd85790509050808202811583838304141715613bd857905090506103005161026051808203828111613bd857905090508015613bd85780820490509050610280525b60206102005160405261022051606052610240516080526102605160a0526102405161028051808201828110613bd8579050905060c05261241e6102a0613a6a565b6102a0f35b63db93cc7a811861265e5760043610613bd857600054600214613bd85760026000556c050c783eb9b5c840000000001b54636a627842604052600254606052602060406024605c6000855af161247e573d600060003e3d6000fd5b60203d10613bd857604050506c050c783eb9b5c840000000001c546370a0823160605230608052602060606024607c845afa6124bf573d600060003e3d6000fd5b60203d10613bd857606090505160405260405115612657576c050c783eb9b5c840000000001f546c050c783eb9b5c840000000001d54186125d4576c050c783eb9b5c840000000001c5463095ea7b36060526c050c783eb9b5c840000000001f5460805260405160a052602060606044607c6000855af1612545573d600060003e3d6000fd5b3d61255b57803b15613bd857600160c052612572565b60203d10613bd8576060518060011c613bd85760c0525b60c090505115613bd8576c050c783eb9b5c840000000001f5463338b5dea6060526c050c783eb9b5c840000000001c5460805260405160a052803b15613bd857600060606044607c6000855af16125ce573d600060003e3d6000fd5b50612657565b6c050c783eb9b5c840000000001c5463a9059cbb6060526c050c783eb9b5c840000000001d5460805260405160a052602060606044607c6000855af161261f573d600060003e3d6000fd5b3d61263557803b15613bd857600160c05261264c565b60203d10613bd8576060518060011c613bd85760c0525b60c090505115613bd8575b6003600055005b63cb8e090d81186128065760243610613bd8576004358060a01c613bd8576040526c050c783eb9b5c8400000000011543318156126f25760066060527f2161646d696e000000000000000000000000000000000000000000000000000060805260605060605180608001601f826000031636823750506308c379a06020526020604052601f19601f6060510116604401603cfd5b6c050c783eb9b5c840000000001e5461276257600a6060527f21617661696c61626c650000000000000000000000000000000000000000000060805260605060605180608001601f826000031636823750506308c379a06020526020604052601f19601f6060510116604401603cfd5b6040516127c65760066060527f21656d707479000000000000000000000000000000000000000000000000000060805260605060605180608001601f826000031636823750506308c379a06020526020604052601f19601f6060510116604401603cfd5b6040516c050c783eb9b5c840000000001d557f454ccf21cdac2e344d64173597f5922657c25c5b1e6c3f733791637513d2063760405160605260206060a1005b63ee00ef3a81186128255760043610613bd85760015460405260206040f35b6382bfefc881186128445760043610613bd85760025460405260206040f35b63047fc9aa81186128635760043610613bd85760095460405260206040f35b63cbf9fe5f81186128a85760243610613bd8576004358060a01c613bd857604052600a6040516020526000526040600020805460605260018101546080525060406060f35b63900cf0cf81186128c75760043610613bd857600b5460405260206040f35b63d1febfb9811861291b5760243610613bd8576004356c01431e0fae6d7217ca9fffffff8111613bd85760021b600c01805460405260018101546060526002810154608052600381015460a0525060806040f35b6328d09d4781186129915760443610613bd8576004358060a01c613bd8576040526c050c783eb9b5c840000000000c6040516020526000526040600020602435633b9ac9ff8111613bd85760021b8101905080546060526001810154608052600281015460a052600381015460c0525060806060f35b63010ae75781186129d85760243610613bd8576004358060a01c613bd8576040526c050c783eb9b5c840000000000d60405160205260005260406000205460605260206060f35b63711974848118612a115760243610613bd8576c050c783eb9b5c840000000000e60043560205260005260406000205460405260206040f35b638ff36fd18118612a3c5760043610613bd8576c050c783eb9b5c840000000000f5460405260206040f35b637175d4f78118612a675760043610613bd8576c050c783eb9b5c84000000000105460405260206040f35b63f851a4408118612a925760043610613bd8576c050c783eb9b5c84000000000115460405260206040f35b63142614258118612abd5760043610613bd8576c050c783eb9b5c84000000000125460405260206040f35b6322cf35f58118612ae85760043610613bd8576c050c783eb9b5c84000000000135460405260206040f35b6317f7182a8118612b135760043610613bd8576c050c783eb9b5c84000000000145460405260206040f35b639a01873c8118612b3e5760043610613bd8576c050c783eb9b5c84000000000155460405260206040f35b63f68467278118612b695760043610613bd8576c050c783eb9b5c84000000000165460405260206040f35b63cd8c79aa8118612b945760043610613bd8576c050c783eb9b5c84000000000175460405260206040f35b63094cda238118612bbf5760043610613bd8576c050c783eb9b5c84000000000185460405260206040f35b63205ad4088118612bea5760043610613bd8576c050c783eb9b5c84000000000195460405260206040f35b635836ec3a8118612c155760043610613bd8576c050c783eb9b5c840000000001a5460405260206040f35b6373f43d6d8118612c405760043610613bd8576c050c783eb9b5c840000000001b5460405260206040f35b6338d546458118612c6b5760043610613bd8576c050c783eb9b5c840000000001c5460405260206040f35b631dac30b08118612c965760043610613bd8576c050c783eb9b5c840000000001d5460405260206040f35b63b027651a8118612cc15760043610613bd8576c050c783eb9b5c840000000001e5460405260206040f35b63acc2166a8118612cec5760043610613bd8576c050c783eb9b5c840000000001f5460405260206040f35b63b3d8f7e78118612d175760043610613bd8576c050c783eb9b5c84000000000205460405260206040f35b505b60006000fd5b3260405114612e15576c050c783eb9b5c84000000000105460605260605115612d945760605163c23697a860805260405160a052602060806024609c6000855af1612d6f573d600060003e3d6000fd5b60203d10613bd8576080518060011c613bd85760c05260c090505115612d9457612e15565b60256080527f536d61727420636f6e7472616374206465706f7369746f7273206e6f7420616c60a0527f6c6f77656400000000000000000000000000000000000000000000000000000060c0526080506080518060a001601f826000031636823750506308c379a06040526020606052601f19601f6080510116604401605cfd5b565b6101403660e037600b546102205260405115612f83574260805111612e3d576000612e45565b600160605112155b15612ea65760605160015480607f1c613bd8578015613bd85780820580600f0b8118613bd85790509050610100526101005160805142808203828111613bd8579050905080607f1c613bd85780820280600f0b8118613bd8579050905060e0525b4260c05111612eb6576000612ebe565b600160a05112155b15612f205760a05160015480607f1c613bd8578015613bd85780820580600f0b8118613bd85790509050610180526101805160c05142808203828111613bd8579050905080607f1c613bd85780820280600f0b8118613bd85790509050610160525b6c050c783eb9b5c840000000000e6080516020526000526040600020546101e05260c05115612f835760805160c05118612f61576101e05161020052612f83565b6c050c783eb9b5c840000000000e60c051602052600052604060002054610200525b604036610240374261028052436102a0526102205115612fde57610220516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c0180546102405260018101546102605260028101546102805260038101546102a052505b610280516102c052610240516102e052610260516103005261028051610320526102a051610340526000610360526102805142111561306d57436102a051808203828111613bd85790509050670de0b6b3a7640000810281670de0b6b3a7640000820418613bd85790504261028051808203828111613bd857905090508015613bd85780820490509050610360525b6102c05162093a808104905062093a8081028162093a80820418613bd857905061038052600060ff905b806103a0526103805162093a808101818110613bd85790506103805260006103c0524261038051116130ea576c050c783eb9b5c840000000000e610380516020526000526040600020546103c0526130f0565b42610380525b6102405161026051610380516102c051808203828111613bd8579050905080607f1c613bd85780820280600f0b8118613bd8579050905080820380600f0b8118613bd8579050905061024052610260516103c05180820180600f0b8118613bd85790509050610260527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff610240511361318a576000610240525b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff61026051136131bb576000610260525b610380516102c052610380516102805261034051610360516103805161032051808203828111613bd85790509050808202811583838304141715613bd85790509050670de0b6b3a764000081049050808201828110613bd857905090506102a0526102205160018101818110613bd85790506102205242610380511861324957436102a0526132955661328a565b610220516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c016102405181556102605160018201556102805160028201556102a0516003820155505b600101818118613097575b505061022051600b556040511561336b5761026051610180516101005180820380600f0b8118613bd8579050905080820180600f0b8118613bd8579050905061026052610240516101605160e05180820380600f0b8118613bd8579050905080820180600f0b8118613bd85790509050610240527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff610260511361333a576000610260525b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff610240511361336b576000610240525b610220516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c016102405181556102605160018201556102805160028201556102a0516003820155506040511561352857426080511115613425576101e0516101005180820180600f0b8118613bd857905090506101e05260805160c05118613403576101e0516101805180820380600f0b8118613bd857905090506101e0525b6101e0516c050c783eb9b5c840000000000e6080516020526000526040600020555b4260c051111561347a5760805160c051111561347a57610200516101805180820380600f0b8118613bd8579050905061020052610200516c050c783eb9b5c840000000000e60c0516020526000526040600020555b6c050c783eb9b5c840000000000d60405160205260005260406000205460018101818110613bd85790506103a0526103a0516c050c783eb9b5c840000000000d604051602052600052604060002055426101a052436101c0526c050c783eb9b5c840000000000c60405160205260005260406000206103a051633b9ac9ff8111613bd85760021b810190506101605181556101805160018201556101a05160028201556101c0516003820155505b565b6c050c783eb9b5c840000000002054156135a45760156104a0527f616c6c20756e6c6f636b65642c6e6f2073656e736500000000000000000000006104c0526104a0506104a051806104c001601f826000031636823750506308c379a061046052602061048052601f19601f6104a051011660440161047cfd5b610440516104a052610460516104c0526009546104e0526104e05161040051808201828110613bd857905090506009556104a051610500526104c051610520526104a0516104005180607f1c613bd85780820180600f0b8118613bd857905090506104a052610420511561361b57610420516104c0525b600a6103e05160205260005260406000206104a05181556104c0516001820155506103e05160405261050051606052610520516080526104a05160a0526104c05160c052613667612e17565b61040051156136eb576002546323b872dd610540526103e051610560523061058052610400516105a0526020610540606461055c6000855af16136af573d600060003e3d6000fd5b3d6136c657803b15613bd85760016105c0526136df565b60203d10613bd857610540518060011c613bd8576105c0525b6105c090505115613bd8575b6104c0516103e0517f4566dfc29f6f11d13a418c26a02bef7c28bae749d4de47e4e6a7cddea6730d596104005161054052610480516105605242610580526060610540a37f5e2aa66efd74cce82b21852e317e5490d9ecc9e6bb953ae24d90851258cc2f5c6104e051610540526104e05161040051808201828110613bd85790509050610560526040610540a1565b600060805260605160a05260006080905b8060c05260a0516080511061379f57613820565b60805160a051808201828110613bd8579050905060018101818110613bd85790508060011c905060e05260405160e0516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c0160038101905054111561380e5760e05160018103818111613bd857905060a052613815565b60e0516080525b60010181811861378b575b5050608051815250565b600060805260605160a05260006080905b8060c05260a0516080511061384f576138d0565b60805160a051808201828110613bd8579050905060018101818110613bd85790508060011c905060e05260405160e0516c01431e0fae6d7217ca9fffffff8111613bd85760021b600c016002810190505411156138be5760e05160018103818111613bd857905060a0526138c5565b60e0516080525b60010181811861383b575b5050608051815250565b600060a05260805160c05260006080905b8060e05260c05160a051106138ff57613998565b60a05160c051808201828110613bd8579050905060018101818110613bd85790508060011c9050610100526060516c050c783eb9b5c840000000000c604051602052600052604060002061010051633b9ac9ff8111613bd85760021b81019050600381019050541115613985576101005160018103818111613bd857905060c05261398d565b6101005160a0525b6001018181186138eb575b505060a051815250565b600060a05260805160c05260006080905b8060e05260c05160a051106139c757613a60565b60a05160c051808201828110613bd8579050905060018101818110613bd85790508060011c9050610100526060516c050c783eb9b5c840000000000c604051602052600052604060002061010051633b9ac9ff8111613bd85760021b81019050600281019050541115613a4d576101005160018103818111613bd857905060c052613a55565b6101005160a0525b6001018181186139b3575b505060a051815250565b60405160e052606051610100526080516101205260a051610140526101205162093a808104905062093a8081028162093a80820418613bd857905061016052600060ff905b80610180526101605162093a808101818110613bd85790506101605260006101a05260c0516101605111613b04576c050c783eb9b5c840000000000e610160516020526000526040600020546101a052613b0c565b60c051610160525b60e051610100516101605161012051808203828111613bd8579050905080607f1c613bd85780820280600f0b8118613bd8579050905080820380600f0b8118613bd8579050905060e05260c0516101605118613b6757613b97565b610100516101a05180820180600f0b8118613bd85790509050610100526101605161012052600101818118613aaf575b50507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff60e05113613bc857600060e0525b60e05160008112613bd857815250565b600080fda165767970657283000307000b", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/apps/api/src/app/contracts/bytecodes/BPT.json b/apps/api/src/app/contracts/bytecodes/BPT.json new file mode 100644 index 000000000..2da6a189c --- /dev/null +++ b/apps/api/src/app/contracts/bytecodes/BPT.json @@ -0,0 +1,3 @@ +{ + "bytecode": "0x608060405234801561001057600080fd5b50604080518082018252600c8082526b06460aaa688865a7060a890b60a31b6020808401828152855180870190965292855284015281519192916100569160039161007f565b50805161006a90600490602084019061007f565b50506005805460ff1916601217905550610120565b828054600181600116156101000203166002900490600052602060002090601f0160209004810192826100b557600085556100fb565b82601f106100ce57805160ff19168380011785556100fb565b828001600101855582156100fb579182015b828111156100fb5782518255916020019190600101906100e0565b5061010792915061010b565b5090565b5b80821115610107576000815560010161010c565b610b298061012f6000396000f3fe608060405234801561001057600080fd5b50600436106100b45760003560e01c806340c10f191161007157806340c10f191461021057806370a082311461023e57806395d89b4114610264578063a457c2d71461026c578063a9059cbb14610298578063dd62ed3e146102c4576100b4565b806306fdde03146100b9578063095ea7b31461013657806318160ddd1461017657806323b872dd14610190578063313ce567146101c657806339509351146101e4575b600080fd5b6100c16102f2565b6040805160208082528351818301528351919283929083019185019080838360005b838110156100fb5781810151838201526020016100e3565b50505050905090810190601f1680156101285780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6101626004803603604081101561014c57600080fd5b506001600160a01b038135169060200135610388565b604080519115158252519081900360200190f35b61017e6103a5565b60408051918252519081900360200190f35b610162600480360360608110156101a657600080fd5b506001600160a01b038135811691602081013590911690604001356103ab565b6101ce610432565b6040805160ff9092168252519081900360200190f35b610162600480360360408110156101fa57600080fd5b506001600160a01b03813516906020013561043b565b61023c6004803603604081101561022657600080fd5b506001600160a01b038135169060200135610489565b005b61017e6004803603602081101561025457600080fd5b50356001600160a01b0316610497565b6100c16104b2565b6101626004803603604081101561028257600080fd5b506001600160a01b038135169060200135610513565b610162600480360360408110156102ae57600080fd5b506001600160a01b03813516906020013561057b565b61017e600480360360408110156102da57600080fd5b506001600160a01b038135811691602001351661058f565b60038054604080516020601f600260001961010060018816150201909516949094049384018190048102820181019092528281526060939092909183018282801561037e5780601f106103535761010080835404028352916020019161037e565b820191906000526020600020905b81548152906001019060200180831161036157829003601f168201915b5050505050905090565b600061039c6103956105ba565b84846105be565b50600192915050565b60025490565b60006103b88484846106aa565b610428846103c46105ba565b61042385604051806060016040528060288152602001610a5e602891396001600160a01b038a166000908152600160205260408120906104026105ba565b6001600160a01b031681526020810191909152604001600020549190610805565b6105be565b5060019392505050565b60055460ff1690565b600061039c6104486105ba565b8461042385600160006104596105ba565b6001600160a01b03908116825260208083019390935260409182016000908120918c16815292529020549061089c565b61049382826108fd565b5050565b6001600160a01b031660009081526020819052604090205490565b60048054604080516020601f600260001961010060018816150201909516949094049384018190048102820181019092528281526060939092909183018282801561037e5780601f106103535761010080835404028352916020019161037e565b600061039c6105206105ba565b8461042385604051806060016040528060258152602001610acf602591396001600061054a6105ba565b6001600160a01b03908116825260208083019390935260409182016000908120918d16815292529020549190610805565b600061039c6105886105ba565b84846106aa565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b3390565b6001600160a01b0383166106035760405162461bcd60e51b8152600401808060200182810382526024815260200180610aab6024913960400191505060405180910390fd5b6001600160a01b0382166106485760405162461bcd60e51b8152600401808060200182810382526022815260200180610a166022913960400191505060405180910390fd5b6001600160a01b03808416600081815260016020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b6001600160a01b0383166106ef5760405162461bcd60e51b8152600401808060200182810382526025815260200180610a866025913960400191505060405180910390fd5b6001600160a01b0382166107345760405162461bcd60e51b81526004018080602001828103825260238152602001806109f36023913960400191505060405180910390fd5b61073f8383836109ed565b61077c81604051806060016040528060268152602001610a38602691396001600160a01b0386166000908152602081905260409020549190610805565b6001600160a01b0380851660009081526020819052604080822093909355908416815220546107ab908261089c565b6001600160a01b038084166000818152602081815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600081848411156108945760405162461bcd60e51b81526004018080602001828103825283818151815260200191508051906020019080838360005b83811015610859578181015183820152602001610841565b50505050905090810190601f1680156108865780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b505050900390565b6000828201838110156108f6576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b6001600160a01b038216610958576040805162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015290519081900360640190fd5b610964600083836109ed565b600254610971908261089c565b6002556001600160a01b038216600090815260208190526040902054610997908261089c565b6001600160a01b0383166000818152602081815260408083209490945583518581529351929391927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9281900390910190a35050565b50505056fe45524332303a207472616e7366657220746f20746865207a65726f206164647265737345524332303a20617070726f766520746f20746865207a65726f206164647265737345524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e636545524332303a207472616e7366657220616d6f756e74206578636565647320616c6c6f77616e636545524332303a207472616e736665722066726f6d20746865207a65726f206164647265737345524332303a20617070726f76652066726f6d20746865207a65726f206164647265737345524332303a2064656372656173656420616c6c6f77616e63652062656c6f77207a65726fa2646970667358221220be90cb02cc1a78ae7b1a2ac653ffebd3904718b4b07da4792fe1a8b0a4f49ffb64736f6c63430007060033" +} \ No newline at end of file diff --git a/apps/api/src/app/contracts/bytecodes/LimitedSupplyToken.json b/apps/api/src/app/contracts/bytecodes/LimitedSupplyToken.json new file mode 100644 index 000000000..3398811c6 --- /dev/null +++ b/apps/api/src/app/contracts/bytecodes/LimitedSupplyToken.json @@ -0,0 +1,3 @@ +{ + "bytecode": "0x60806040523480156200001157600080fd5b5060405162000e2538038062000e25833981810160405260808110156200003757600080fd5b81019080805160405193929190846401000000008211156200005857600080fd5b9083019060208201858111156200006e57600080fd5b82516401000000008111828201881017156200008957600080fd5b82525081516020918201929091019080838360005b83811015620000b85781810151838201526020016200009e565b50505050905090810190601f168015620000e65780820380516001836020036101000a031916815260200191505b50604052602001805160405193929190846401000000008211156200010a57600080fd5b9083019060208201858111156200012057600080fd5b82516401000000008111828201881017156200013b57600080fd5b82525081516020918201929091019080838360005b838110156200016a57818101518382015260200162000150565b50505050905090810190601f168015620001985780820380516001836020036101000a031916815260200191505b50604090815260208281015192909101518651929450925085918591620001c59160039185019062000377565b508051620001db90600490602084019062000377565b50506005805460ff1916601217905550620001f7828262000201565b5050505062000423565b6001600160a01b0382166200025d576040805162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015290519081900360640190fd5b6200026b6000838362000310565b62000287816002546200031560201b620005731790919060201c565b6002556001600160a01b03821660009081526020818152604090912054620002ba9183906200057362000315821b17901c565b6001600160a01b0383166000818152602081815260408083209490945583518581529351929391927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9281900390910190a35050565b505050565b60008282018381101562000370576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b828054600181600116156101000203166002900490600052602060002090601f016020900481019282620003af5760008555620003fa565b82601f10620003ca57805160ff1916838001178555620003fa565b82800160010185558215620003fa579182015b82811115620003fa578251825591602001919060010190620003dd565b50620004089291506200040c565b5090565b5b808211156200040857600081556001016200040d565b6109f280620004336000396000f3fe608060405234801561001057600080fd5b50600436106100a95760003560e01c8063395093511161007157806339509351146101d957806370a082311461020557806395d89b411461022b578063a457c2d714610233578063a9059cbb1461025f578063dd62ed3e1461028b576100a9565b806306fdde03146100ae578063095ea7b31461012b57806318160ddd1461016b57806323b872dd14610185578063313ce567146101bb575b600080fd5b6100b66102b9565b6040805160208082528351818301528351919283929083019185019080838360005b838110156100f05781810151838201526020016100d8565b50505050905090810190601f16801561011d5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6101576004803603604081101561014157600080fd5b506001600160a01b03813516906020013561034f565b604080519115158252519081900360200190f35b61017361036c565b60408051918252519081900360200190f35b6101576004803603606081101561019b57600080fd5b506001600160a01b03813581169160208101359091169060400135610372565b6101c36103f9565b6040805160ff9092168252519081900360200190f35b610157600480360360408110156101ef57600080fd5b506001600160a01b038135169060200135610402565b6101736004803603602081101561021b57600080fd5b50356001600160a01b0316610450565b6100b661046b565b6101576004803603604081101561024957600080fd5b506001600160a01b0381351690602001356104cc565b6101576004803603604081101561027557600080fd5b506001600160a01b038135169060200135610534565b610173600480360360408110156102a157600080fd5b506001600160a01b0381358116916020013516610548565b60038054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156103455780601f1061031a57610100808354040283529160200191610345565b820191906000526020600020905b81548152906001019060200180831161032857829003601f168201915b5050505050905090565b600061036361035c6105d4565b84846105d8565b50600192915050565b60025490565b600061037f8484846106c4565b6103ef8461038b6105d4565b6103ea85604051806060016040528060288152602001610927602891396001600160a01b038a166000908152600160205260408120906103c96105d4565b6001600160a01b03168152602081019190915260400160002054919061081f565b6105d8565b5060019392505050565b60055460ff1690565b600061036361040f6105d4565b846103ea85600160006104206105d4565b6001600160a01b03908116825260208083019390935260409182016000908120918c168152925290205490610573565b6001600160a01b031660009081526020819052604090205490565b60048054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156103455780601f1061031a57610100808354040283529160200191610345565b60006103636104d96105d4565b846103ea8560405180606001604052806025815260200161099860259139600160006105036105d4565b6001600160a01b03908116825260208083019390935260409182016000908120918d1681529252902054919061081f565b60006103636105416105d4565b84846106c4565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b6000828201838110156105cd576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b9392505050565b3390565b6001600160a01b03831661061d5760405162461bcd60e51b81526004018080602001828103825260248152602001806109746024913960400191505060405180910390fd5b6001600160a01b0382166106625760405162461bcd60e51b81526004018080602001828103825260228152602001806108df6022913960400191505060405180910390fd5b6001600160a01b03808416600081815260016020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b6001600160a01b0383166107095760405162461bcd60e51b815260040180806020018281038252602581526020018061094f6025913960400191505060405180910390fd5b6001600160a01b03821661074e5760405162461bcd60e51b81526004018080602001828103825260238152602001806108bc6023913960400191505060405180910390fd5b6107598383836108b6565b61079681604051806060016040528060268152602001610901602691396001600160a01b038616600090815260208190526040902054919061081f565b6001600160a01b0380851660009081526020819052604080822093909355908416815220546107c59082610573565b6001600160a01b038084166000818152602081815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600081848411156108ae5760405162461bcd60e51b81526004018080602001828103825283818151815260200191508051906020019080838360005b8381101561087357818101518382015260200161085b565b50505050905090810190601f1680156108a05780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b505050900390565b50505056fe45524332303a207472616e7366657220746f20746865207a65726f206164647265737345524332303a20617070726f766520746f20746865207a65726f206164647265737345524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e636545524332303a207472616e7366657220616d6f756e74206578636565647320616c6c6f77616e636545524332303a207472616e736665722066726f6d20746865207a65726f206164647265737345524332303a20617070726f76652066726f6d20746865207a65726f206164647265737345524332303a2064656372656173656420616c6c6f77616e63652062656c6f77207a65726fa26469706673582212203a39f8bf2c5bf459724ea963014e3edc079b5f574b282d1055434bdac0ced92a64736f6c63430007060033" +} \ No newline at end of file diff --git a/apps/api/src/app/contracts/bytecodes/NonFungibleToken.json b/apps/api/src/app/contracts/bytecodes/NonFungibleToken.json new file mode 100644 index 000000000..1f4d9d629 --- /dev/null +++ b/apps/api/src/app/contracts/bytecodes/NonFungibleToken.json @@ -0,0 +1,3 @@ +{ + "bytecode": "0x60806040523480156200001157600080fd5b5060405162002bf438038062002bf4833981810160405260808110156200003757600080fd5b81019080805160405193929190846401000000008211156200005857600080fd5b9083019060208201858111156200006e57600080fd5b82516401000000008111828201881017156200008957600080fd5b82525081516020918201929091019080838360005b83811015620000b85781810151838201526020016200009e565b50505050905090810190601f168015620000e65780820380516001836020036101000a031916815260200191505b50604052602001805160405193929190846401000000008211156200010a57600080fd5b9083019060208201858111156200012057600080fd5b82516401000000008111828201881017156200013b57600080fd5b82525081516020918201929091019080838360005b838110156200016a57818101518382015260200162000150565b50505050905090810190601f168015620001985780820380516001836020036101000a031916815260200191505b5060405260200180516040519392919084640100000000821115620001bc57600080fd5b908301906020820185811115620001d257600080fd5b8251640100000000811182820188101715620001ed57600080fd5b82525081516020918201929091019080838360005b838110156200021c57818101518382015260200162000202565b50505050905090810190601f1680156200024a5780820380516001836020036101000a031916815260200191505b50604052602001519150849050836200026a6301ffc9a760e01b62000371565b81516200027f9060069060208501906200063b565b508051620002959060079060208401906200063b565b50620002a86380ac58cd60e01b62000371565b620002ba635b5e139f60e01b62000371565b620002cc63780e9d6360e01b62000371565b5060009050620002db620003f6565b600b80546001600160a01b0319166001600160a01b0383169081179091556040519192509060009060008051602062002bd4833981519152908290a3506200032381620003fa565b6200033060008262000505565b6200035c7f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a68262000505565b620003678262000515565b50505050620006e7565b6001600160e01b03198082161415620003d1576040805162461bcd60e51b815260206004820152601c60248201527f4552433136353a20696e76616c696420696e7465726661636520696400000000604482015290519081900360640190fd5b6001600160e01b0319166000908152602081905260409020805460ff19166001179055565b3390565b62000404620003f6565b6001600160a01b0316620004176200052a565b6001600160a01b03161462000473576040805162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e6572604482015290519081900360640190fd5b6001600160a01b038116620004ba5760405162461bcd60e51b815260040180806020018281038252602681526020018062002bae6026913960400191505060405180910390fd5b600b546040516001600160a01b0380841692169060008051602062002bd483398151915290600090a3600b80546001600160a01b0319166001600160a01b0392909216919091179055565b62000511828262000539565b5050565b8051620005119060099060208401906200063b565b600b546001600160a01b031690565b6000828152600a6020908152604090912062000560918390620013a9620005b4821b17901c565b15620005115762000570620003f6565b6001600160a01b0316816001600160a01b0316837f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45050565b6000620005cb836001600160a01b038416620005d4565b90505b92915050565b6000620005e2838362000623565b6200061a57508154600181810184556000848152602080822090930184905584548482528286019093526040902091909155620005ce565b506000620005ce565b60009081526001919091016020526040902054151590565b828054600181600116156101000203166002900490600052602060002090601f016020900481019282620006735760008555620006be565b82601f106200068e57805160ff1916838001178555620006be565b82800160010185558215620006be579182015b82811115620006be578251825591602001919060010190620006a1565b50620006cc929150620006d0565b5090565b5b80821115620006cc5760008155600101620006d1565b6124b780620006f76000396000f3fe608060405234801561001057600080fd5b50600436106101da5760003560e01c8063715018a611610104578063b88d4fde116100a2578063d539139311610071578063d5391393146106e0578063d547741f146106e8578063e985e9c514610714578063f2fde38b14610742576101da565b8063b88d4fde1461052a578063c87b56dd146105f0578063ca15c8731461060d578063d0def5211461062a576101da565b806391d14854116100de57806391d14854146104c057806395d89b41146104ec578063a217fddf146104f4578063a22cb465146104fc576101da565b8063715018a61461048d5780638da5cb5b146104955780639010d07c1461049d576101da565b80632f2ff15d1161017c5780634f6ccce71161014b5780634f6ccce7146104255780636352211e146104425780636c0360eb1461045f57806370a0823114610467576101da565b80632f2ff15d1461036b5780632f745c591461039757806336568abe146103c357806342842e0e146103ef576101da565b8063095ea7b3116101b8578063095ea7b3146102d057806318160ddd146102fe57806323b872dd14610318578063248a9ca31461034e576101da565b806301ffc9a7146101df57806306fdde031461021a578063081812fc14610297575b600080fd5b610206600480360360208110156101f557600080fd5b50356001600160e01b031916610768565b604080519115158252519081900360200190f35b61022261078b565b6040805160208082528351818301528351919283929083019185019080838360005b8381101561025c578181015183820152602001610244565b50505050905090810190601f1680156102895780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6102b4600480360360208110156102ad57600080fd5b5035610821565b604080516001600160a01b039092168252519081900360200190f35b6102fc600480360360408110156102e657600080fd5b506001600160a01b038135169060200135610883565b005b61030661095e565b60408051918252519081900360200190f35b6102fc6004803603606081101561032e57600080fd5b506001600160a01b0381358116916020810135909116906040013561096f565b6103066004803603602081101561036457600080fd5b50356109c6565b6102fc6004803603604081101561038157600080fd5b50803590602001356001600160a01b03166109db565b610306600480360360408110156103ad57600080fd5b506001600160a01b038135169060200135610a47565b6102fc600480360360408110156103d957600080fd5b50803590602001356001600160a01b0316610a72565b6102fc6004803603606081101561040557600080fd5b506001600160a01b03813581169160208101359091169060400135610ad3565b6103066004803603602081101561043b57600080fd5b5035610aee565b6102b46004803603602081101561045857600080fd5b5035610b04565b610222610b2c565b6103066004803603602081101561047d57600080fd5b50356001600160a01b0316610b8d565b6102fc610bf5565b6102b4610cb3565b6102b4600480360360408110156104b357600080fd5b5080359060200135610cc2565b610206600480360360408110156104d657600080fd5b50803590602001356001600160a01b0316610cda565b610222610cf2565b610306610d53565b6102fc6004803603604081101561051257600080fd5b506001600160a01b0381351690602001351515610d58565b6102fc6004803603608081101561054057600080fd5b6001600160a01b0382358116926020810135909116916040820135919081019060808101606082013564010000000081111561057b57600080fd5b82018360208201111561058d57600080fd5b803590602001918460018302840111640100000000831117156105af57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600092019190915250929550610e5d945050505050565b6102226004803603602081101561060657600080fd5b5035610ebb565b6103066004803603602081101561062357600080fd5b503561113c565b6103066004803603604081101561064057600080fd5b6001600160a01b03823516919081019060408101602082013564010000000081111561066b57600080fd5b82018360208201111561067d57600080fd5b8035906020019184600183028401116401000000008311171561069f57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600092019190915250929550611153945050505050565b6103066111e9565b6102fc600480360360408110156106fe57600080fd5b50803590602001356001600160a01b031661120d565b6102066004803603604081101561072a57600080fd5b506001600160a01b0381358116916020013516611266565b6102fc6004803603602081101561075857600080fd5b50356001600160a01b0316611294565b6001600160e01b0319811660009081526020819052604090205460ff165b919050565b60068054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156108175780601f106107ec57610100808354040283529160200191610817565b820191906000526020600020905b8154815290600101906020018083116107fa57829003601f168201915b5050505050905090565b600061082c826113be565b6108675760405162461bcd60e51b815260040180806020018281038252602c815260200180612351602c913960400191505060405180910390fd5b506000908152600460205260409020546001600160a01b031690565b600061088e82610b04565b9050806001600160a01b0316836001600160a01b031614156108e15760405162461bcd60e51b81526004018080602001828103825260218152602001806124016021913960400191505060405180910390fd5b806001600160a01b03166108f36113cb565b6001600160a01b0316148061091457506109148161090f6113cb565b611266565b61094f5760405162461bcd60e51b81526004018080602001828103825260388152602001806122a46038913960400191505060405180910390fd5b61095983836113cf565b505050565b600061096a600261143d565b905090565b61098061097a6113cb565b82611448565b6109bb5760405162461bcd60e51b81526004018080602001828103825260318152602001806124226031913960400191505060405180910390fd5b6109598383836114ec565b6000908152600a602052604090206002015490565b6000828152600a60205260409020600201546109fe906109f96113cb565b610cda565b610a395760405162461bcd60e51b815260040180806020018281038252602f81526020018061219d602f913960400191505060405180910390fd5b610a438282611638565b5050565b6001600160a01b0382166000908152600160205260408120610a6990836116a1565b90505b92915050565b610a7a6113cb565b6001600160a01b0316816001600160a01b031614610ac95760405162461bcd60e51b815260040180806020018281038252602f815260200180612453602f913960400191505060405180910390fd5b610a4382826116ad565b61095983838360405180602001604052806000815250610e5d565b600080610afc600284611716565b509392505050565b6000610a6c826040518060600160405280602981526020016123066029913960029190611732565b60098054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156108175780601f106107ec57610100808354040283529160200191610817565b60006001600160a01b038216610bd45760405162461bcd60e51b815260040180806020018281038252602a8152602001806122dc602a913960400191505060405180910390fd5b6001600160a01b0382166000908152600160205260409020610a6c9061143d565b610bfd6113cb565b6001600160a01b0316610c0e610cb3565b6001600160a01b031614610c69576040805162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e6572604482015290519081900360640190fd5b600b546040516000916001600160a01b0316907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0908390a3600b80546001600160a01b0319169055565b600b546001600160a01b031690565b6000828152600a60205260408120610a6990836116a1565b6000828152600a60205260408120610a699083611749565b60078054604080516020601f60026000196101006001881615020190951694909404938401819004810282018101909252828152606093909290918301828280156108175780601f106107ec57610100808354040283529160200191610817565b600081565b610d606113cb565b6001600160a01b0316826001600160a01b03161415610dc6576040805162461bcd60e51b815260206004820152601960248201527f4552433732313a20617070726f766520746f2063616c6c657200000000000000604482015290519081900360640190fd5b8060056000610dd36113cb565b6001600160a01b03908116825260208083019390935260409182016000908120918716808252919093529120805460ff191692151592909217909155610e176113cb565b6001600160a01b03167f17307eab39ab6107e8899845ad3d59bd9653f200f220920489ca2b5937696c318360405180821515815260200191505060405180910390a35050565b610e6e610e686113cb565b83611448565b610ea95760405162461bcd60e51b81526004018080602001828103825260318152602001806124226031913960400191505060405180910390fd5b610eb58484848461175e565b50505050565b6060610ec6826113be565b610f015760405162461bcd60e51b815260040180806020018281038252602f8152602001806123d2602f913960400191505060405180910390fd5b60008281526008602090815260408083208054825160026001831615610100026000190190921691909104601f810185900485028201850190935282815292909190830182828015610f945780601f10610f6957610100808354040283529160200191610f94565b820191906000526020600020905b815481529060010190602001808311610f7757829003601f168201915b505050505090506000610fa5610b2c565b9050805160001415610fb957509050610786565b81511561107a5780826040516020018083805190602001908083835b60208310610ff45780518252601f199092019160209182019101610fd5565b51815160209384036101000a600019018019909216911617905285519190930192850191508083835b6020831061103c5780518252601f19909201916020918201910161101d565b6001836020036101000a0380198251168184511680821785525050505050509050019250505060405160208183030381529060405292505050610786565b80611084856117b0565b6040516020018083805190602001908083835b602083106110b65780518252601f199092019160209182019101611097565b51815160209384036101000a600019018019909216911617905285519190930192850191508083835b602083106110fe5780518252601f1990920191602091820191016110df565b6001836020036101000a0380198251168184511680821785525050505050509050019250505060405160208183030381529060405292505050919050565b6000818152600a60205260408120610a6c9061143d565b600061117f7f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a633610cda565b6111bd576040805162461bcd60e51b815260206004820152600a6024820152692727aa2fa6a4a72a22a960b11b604482015290519081900360640190fd5b6111c7600c61188b565b60006111d3600c611894565b90506111df8482611898565b610a6981846119c6565b7f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a681565b6000828152600a602052604090206002015461122b906109f96113cb565b610ac95760405162461bcd60e51b81526004018080602001828103825260308152602001806122746030913960400191505060405180910390fd5b6001600160a01b03918216600090815260056020908152604080832093909416825291909152205460ff1690565b61129c6113cb565b6001600160a01b03166112ad610cb3565b6001600160a01b031614611308576040805162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e6572604482015290519081900360640190fd5b6001600160a01b03811661134d5760405162461bcd60e51b81526004018080602001828103825260268152602001806121fe6026913960400191505060405180910390fd5b600b546040516001600160a01b038084169216907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a3600b80546001600160a01b0319166001600160a01b0392909216919091179055565b6000610a69836001600160a01b038416611a29565b6000610a6c600283611a73565b3390565b600081815260046020526040902080546001600160a01b0319166001600160a01b038416908117909155819061140482610b04565b6001600160a01b03167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92560405160405180910390a45050565b6000610a6c82611894565b6000611453826113be565b61148e5760405162461bcd60e51b815260040180806020018281038252602c815260200180612248602c913960400191505060405180910390fd5b600061149983610b04565b9050806001600160a01b0316846001600160a01b031614806114d45750836001600160a01b03166114c984610821565b6001600160a01b0316145b806114e457506114e48185611266565b949350505050565b826001600160a01b03166114ff82610b04565b6001600160a01b0316146115445760405162461bcd60e51b81526004018080602001828103825260298152602001806123a96029913960400191505060405180910390fd5b6001600160a01b0382166115895760405162461bcd60e51b81526004018080602001828103825260248152602001806122246024913960400191505060405180910390fd5b611594838383610959565b61159f6000826113cf565b6001600160a01b03831660009081526001602052604090206115c19082611a7f565b506001600160a01b03821660009081526001602052604090206115e49082611a8b565b506115f160028284611a97565b5080826001600160a01b0316846001600160a01b03167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef60405160405180910390a4505050565b6000828152600a6020526040902061165090826113a9565b15610a435761165d6113cb565b6001600160a01b0316816001600160a01b0316837f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45050565b6000610a698383611aad565b6000828152600a602052604090206116c59082611b11565b15610a43576116d26113cb565b6001600160a01b0316816001600160a01b0316837ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b60405160405180910390a45050565b60008080806117258686611b26565b9097909650945050505050565b600061173f848484611ba1565b90505b9392505050565b6000610a69836001600160a01b038416611c6b565b6117698484846114ec565b61177584848484611c83565b610eb55760405162461bcd60e51b81526004018080602001828103825260328152602001806121cc6032913960400191505060405180910390fd5b6060816117d557506040805180820190915260018152600360fc1b6020820152610786565b8160005b81156117ed57600101600a820491506117d9565b60008167ffffffffffffffff8111801561180657600080fd5b506040519080825280601f01601f191660200182016040528015611831576020820181803683370190505b50859350905060001982015b831561188257600a840660300160f81b8282806001900393508151811061186057fe5b60200101906001600160f81b031916908160001a905350600a8404935061183d565b50949350505050565b80546001019055565b5490565b6001600160a01b0382166118f3576040805162461bcd60e51b815260206004820181905260248201527f4552433732313a206d696e7420746f20746865207a65726f2061646472657373604482015290519081900360640190fd5b6118fc816113be565b1561194e576040805162461bcd60e51b815260206004820152601c60248201527f4552433732313a20746f6b656e20616c7265616479206d696e74656400000000604482015290519081900360640190fd5b61195a60008383610959565b6001600160a01b038216600090815260016020526040902061197c9082611a8b565b5061198960028284611a97565b5060405181906001600160a01b038416906000907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef908290a45050565b6119cf826113be565b611a0a5760405162461bcd60e51b815260040180806020018281038252602c81526020018061237d602c913960400191505060405180910390fd5b60008281526008602090815260409091208251610959928401906120d9565b6000611a358383611c6b565b611a6b57508154600181810184556000848152602080822090930184905584548482528286019093526040902091909155610a6c565b506000610a6c565b6000610a698383611c6b565b6000610a698383611deb565b6000610a698383611a29565b600061173f84846001600160a01b038516611eb1565b81546000908210611aef5760405162461bcd60e51b815260040180806020018281038252602281526020018061217b6022913960400191505060405180910390fd5b826000018281548110611afe57fe5b9060005260206000200154905092915050565b6000610a69836001600160a01b038416611deb565b815460009081908310611b6a5760405162461bcd60e51b815260040180806020018281038252602281526020018061232f6022913960400191505060405180910390fd5b6000846000018481548110611b7b57fe5b906000526020600020906002020190508060000154816001015492509250509250929050565b60008281526001840160205260408120548281611c3c5760405162461bcd60e51b81526004018080602001828103825283818151815260200191508051906020019080838360005b83811015611c01578181015183820152602001611be9565b50505050905090810190601f168015611c2e5780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b50846000016001820381548110611c4f57fe5b9060005260206000209060020201600101549150509392505050565b60009081526001919091016020526040902054151590565b6000611c97846001600160a01b0316611f48565b611ca3575060016114e4565b6000611db1630a85bd0160e11b611cb86113cb565b88878760405160240180856001600160a01b03168152602001846001600160a01b0316815260200183815260200180602001828103825283818151815260200191508051906020019080838360005b83811015611d1f578181015183820152602001611d07565b50505050905090810190601f168015611d4c5780820380516001836020036101000a031916815260200191505b5095505050505050604051602081830303815290604052906001600160e01b0319166020820180516001600160e01b0383818316178352505050506040518060600160405280603281526020016121cc603291396001600160a01b0388169190611f4e565b90506000818060200190516020811015611dca57600080fd5b50516001600160e01b031916630a85bd0160e11b1492505050949350505050565b60008181526001830160205260408120548015611ea75783546000198083019190810190600090879083908110611e1e57fe5b9060005260206000200154905080876000018481548110611e3b57fe5b600091825260208083209091019290925582815260018981019092526040902090840190558654879080611e6b57fe5b60019003818190600052602060002001600090559055866001016000878152602001908152602001600020600090556001945050505050610a6c565b6000915050610a6c565b600082815260018401602052604081205480611f16575050604080518082018252838152602080820184815286546001818101895560008981528481209551600290930290950191825591519082015586548684528188019092529290912055611742565b82856000016001830381548110611f2957fe5b9060005260206000209060020201600101819055506000915050611742565b3b151590565b606061173f848460008585611f6285611f48565b611fb3576040805162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e7472616374000000604482015290519081900360640190fd5b600080866001600160a01b031685876040518082805190602001908083835b60208310611ff15780518252601f199092019160209182019101611fd2565b6001836020036101000a03801982511681845116808217855250505050505090500191505060006040518083038185875af1925050503d8060008114612053576040519150601f19603f3d011682016040523d82523d6000602084013e612058565b606091505b5091509150612068828286612073565b979650505050505050565b60608315612082575081611742565b8251156120925782518084602001fd5b60405162461bcd60e51b8152602060048201818152845160248401528451859391928392604401919085019080838360008315611c01578181015183820152602001611be9565b828054600181600116156101000203166002900490600052602060002090601f01602090048101928261210f5760008555612155565b82601f1061212857805160ff1916838001178555612155565b82800160010185558215612155579182015b8281111561215557825182559160200191906001019061213a565b50612161929150612165565b5090565b5b80821115612161576000815560010161216656fe456e756d657261626c655365743a20696e646578206f7574206f6620626f756e6473416363657373436f6e74726f6c3a2073656e646572206d75737420626520616e2061646d696e20746f206772616e744552433732313a207472616e7366657220746f206e6f6e20455243373231526563656976657220696d706c656d656e7465724f776e61626c653a206e6577206f776e657220697320746865207a65726f20616464726573734552433732313a207472616e7366657220746f20746865207a65726f20616464726573734552433732313a206f70657261746f7220717565727920666f72206e6f6e6578697374656e7420746f6b656e416363657373436f6e74726f6c3a2073656e646572206d75737420626520616e2061646d696e20746f207265766f6b654552433732313a20617070726f76652063616c6c6572206973206e6f74206f776e6572206e6f7220617070726f76656420666f7220616c6c4552433732313a2062616c616e636520717565727920666f7220746865207a65726f20616464726573734552433732313a206f776e657220717565727920666f72206e6f6e6578697374656e7420746f6b656e456e756d657261626c654d61703a20696e646578206f7574206f6620626f756e64734552433732313a20617070726f76656420717565727920666f72206e6f6e6578697374656e7420746f6b656e4552433732314d657461646174613a2055524920736574206f66206e6f6e6578697374656e7420746f6b656e4552433732313a207472616e73666572206f6620746f6b656e2074686174206973206e6f74206f776e4552433732314d657461646174613a2055524920717565727920666f72206e6f6e6578697374656e7420746f6b656e4552433732313a20617070726f76616c20746f2063757272656e74206f776e65724552433732313a207472616e736665722063616c6c6572206973206e6f74206f776e6572206e6f7220617070726f766564416363657373436f6e74726f6c3a2063616e206f6e6c792072656e6f756e636520726f6c657320666f722073656c66a2646970667358221220d0f9e0c6b425beadef6cbc735dc3be571f6eb1fe65676b8fe1eb24031c13f81e64736f6c634300070600334f776e61626c653a206e6577206f776e657220697320746865207a65726f20616464726573738be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0" +} \ No newline at end of file diff --git a/apps/api/src/app/contracts/bytecodes/THX_ERC1155.json b/apps/api/src/app/contracts/bytecodes/THX_ERC1155.json new file mode 100644 index 000000000..f76bdbbb3 --- /dev/null +++ b/apps/api/src/app/contracts/bytecodes/THX_ERC1155.json @@ -0,0 +1,3 @@ +{ + "bytecode": "0x60806040523480156200001157600080fd5b5060405162002b6838038062002b68833981810160405260408110156200003757600080fd5b81019080805160405193929190846401000000008211156200005857600080fd5b9083019060208201858111156200006e57600080fd5b82516401000000008111828201881017156200008957600080fd5b82525081516020918201929091019080838360005b83811015620000b85781810151838201526020016200009e565b50505050905090810190601f168015620000e65780820380516001836020036101000a031916815260200191505b50604052602001519150829050620001056301ffc9a760e01b620001ca565b62000110816200024f565b62000122636cdb3d1360e11b620001ca565b620001346303a24d0760e21b620001ca565b5060006200014162000268565b600580546001600160a01b0319166001600160a01b0383169081179091556040519192509060009060008051602062002b48833981519152908290a35062000189816200026c565b6200019660008262000377565b620001c27f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a68262000377565b505062000540565b6001600160e01b031980821614156200022a576040805162461bcd60e51b815260206004820152601c60248201527f4552433136353a20696e76616c696420696e7465726661636520696400000000604482015290519081900360640190fd5b6001600160e01b0319166000908152602081905260409020805460ff19166001179055565b80516200026490600390602084019062000494565b5050565b3390565b6200027662000268565b6001600160a01b03166200028962000383565b6001600160a01b031614620002e5576040805162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e6572604482015290519081900360640190fd5b6001600160a01b0381166200032c5760405162461bcd60e51b815260040180806020018281038252602681526020018062002b226026913960400191505060405180910390fd5b6005546040516001600160a01b0380841692169060008051602062002b4883398151915290600090a3600580546001600160a01b0319166001600160a01b0392909216919091179055565b62000264828262000392565b6005546001600160a01b031690565b6000828152600460209081526040909120620003b9918390620017116200040d821b17901c565b156200026457620003c962000268565b6001600160a01b0316816001600160a01b0316837f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45050565b600062000424836001600160a01b0384166200042d565b90505b92915050565b60006200043b83836200047c565b620004735750815460018181018455600084815260208082209093018490558454848252828601909352604090209190915562000427565b50600062000427565b60009081526001919091016020526040902054151590565b828054600181600116156101000203166002900490600052602060002090601f016020900481019282620004cc576000855562000517565b82601f10620004e757805160ff191683800117855562000517565b8280016001018555821562000517579182015b8281111562000517578251825591602001919060010190620004fa565b506200052592915062000529565b5090565b5b808211156200052557600081556001016200052a565b6125d280620005506000396000f3fe608060405234801561001057600080fd5b50600436106101415760003560e01c80638da5cb5b116100b8578063ca15c8731161007c578063ca15c87314610925578063d539139314610942578063d547741f1461094a578063e985e9c514610976578063f242432a146109a4578063f2fde38b14610a6d57610141565b80638da5cb5b1461087c5780639010d07c146108a057806391d14854146108c3578063a217fddf146108ef578063a22cb465146108f757610141565b80632eb2c2d61161010a5780632eb2c2d6146104285780632f2ff15d146105e957806336568abe146106155780634e1273f414610641578063715018a6146107b4578063731133e9146107bc57610141565b8062fdd58e1461014657806301ffc9a7146101845780630e89341c146101bf5780631f7fdffa14610251578063248a9ca31461040b575b600080fd5b6101726004803603604081101561015c57600080fd5b506001600160a01b038135169060200135610a93565b60408051918252519081900360200190f35b6101ab6004803603602081101561019a57600080fd5b50356001600160e01b031916610b05565b604080519115158252519081900360200190f35b6101dc600480360360208110156101d557600080fd5b5035610b24565b6040805160208082528351818301528351919283929083019185019080838360005b838110156102165781810151838201526020016101fe565b50505050905090810190601f1680156102435780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6104096004803603608081101561026757600080fd5b6001600160a01b038235169190810190604081016020820135600160201b81111561029157600080fd5b8201836020820111156102a357600080fd5b803590602001918460208302840111600160201b831117156102c457600080fd5b9190808060200260200160405190810160405280939291908181526020018383602002808284376000920191909152509295949360208101935035915050600160201b81111561031357600080fd5b82018360208201111561032557600080fd5b803590602001918460208302840111600160201b8311171561034657600080fd5b9190808060200260200160405190810160405280939291908181526020018383602002808284376000920191909152509295949360208101935035915050600160201b81111561039557600080fd5b8201836020820111156103a757600080fd5b803590602001918460018302840111600160201b831117156103c857600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600092019190915250929550610bbc945050505050565b005b6101726004803603602081101561042157600080fd5b5035610c36565b610409600480360360a081101561043e57600080fd5b6001600160a01b038235811692602081013590911691810190606081016040820135600160201b81111561047157600080fd5b82018360208201111561048357600080fd5b803590602001918460208302840111600160201b831117156104a457600080fd5b9190808060200260200160405190810160405280939291908181526020018383602002808284376000920191909152509295949360208101935035915050600160201b8111156104f357600080fd5b82018360208201111561050557600080fd5b803590602001918460208302840111600160201b8311171561052657600080fd5b9190808060200260200160405190810160405280939291908181526020018383602002808284376000920191909152509295949360208101935035915050600160201b81111561057557600080fd5b82018360208201111561058757600080fd5b803590602001918460018302840111600160201b831117156105a857600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600092019190915250929550610c4b945050505050565b610409600480360360408110156105ff57600080fd5b50803590602001356001600160a01b0316610f49565b6104096004803603604081101561062b57600080fd5b50803590602001356001600160a01b0316610fb5565b6107646004803603604081101561065757600080fd5b810190602081018135600160201b81111561067157600080fd5b82018360208201111561068357600080fd5b803590602001918460208302840111600160201b831117156106a457600080fd5b9190808060200260200160405190810160405280939291908181526020018383602002808284376000920191909152509295949360208101935035915050600160201b8111156106f357600080fd5b82018360208201111561070557600080fd5b803590602001918460208302840111600160201b8311171561072657600080fd5b919080806020026020016040519081016040528093929190818152602001838360200280828437600092019190915250929550611016945050505050565b60408051602080825283518183015283519192839290830191858101910280838360005b838110156107a0578181015183820152602001610788565b505050509050019250505060405180910390f35b610409611102565b610409600480360360808110156107d257600080fd5b6001600160a01b038235169160208101359160408201359190810190608081016060820135600160201b81111561080857600080fd5b82018360208201111561081a57600080fd5b803590602001918460018302840111600160201b8311171561083b57600080fd5b91908080601f0160208091040260200160405190810160405280939291908181526020018383808284376000920191909152509295506111c0945050505050565b610884611234565b604080516001600160a01b039092168252519081900360200190f35b610884600480360360408110156108b657600080fd5b5080359060200135611244565b6101ab600480360360408110156108d957600080fd5b50803590602001356001600160a01b0316611263565b61017261127b565b6104096004803603604081101561090d57600080fd5b506001600160a01b0381351690602001351515611280565b6101726004803603602081101561093b57600080fd5b503561136f565b610172611386565b6104096004803603604081101561096057600080fd5b50803590602001356001600160a01b03166113aa565b6101ab6004803603604081101561098c57600080fd5b506001600160a01b0381358116916020013516611403565b610409600480360360a08110156109ba57600080fd5b6001600160a01b03823581169260208101359091169160408201359160608101359181019060a081016080820135600160201b8111156109f957600080fd5b820183602082011115610a0b57600080fd5b803590602001918460018302840111600160201b83111715610a2c57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600092019190915250929550611431945050505050565b61040960048036036020811015610a8357600080fd5b50356001600160a01b03166115fc565b60006001600160a01b038316610ada5760405162461bcd60e51b815260040180806020018281038252602b8152602001806123a8602b913960400191505060405180910390fd5b5060008181526001602090815260408083206001600160a01b03861684529091529020545b92915050565b6001600160e01b03191660009081526020819052604090205460ff1690565b60038054604080516020601f6002600019610100600188161502019095169490940493840181900481028201810190925282815260609390929091830182828015610bb05780601f10610b8557610100808354040283529160200191610bb0565b820191906000526020600020905b815481529060010190602001808311610b9357829003601f168201915b50505050509050919050565b610be67f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a633611263565b610c24576040805162461bcd60e51b815260206004820152600a6024820152692727aa2fa6a4a72a22a960b11b604482015290519081900360640190fd5b610c3084848484611726565b50505050565b60009081526004602052604090206002015490565b8151835114610c8b5760405162461bcd60e51b81526004018080602001828103825260288152602001806125256028913960400191505060405180910390fd5b6001600160a01b038416610cd05760405162461bcd60e51b81526004018080602001828103825260258152602001806124526025913960400191505060405180910390fd5b610cd861197b565b6001600160a01b0316856001600160a01b03161480610d035750610d0385610cfe61197b565b611403565b610d3e5760405162461bcd60e51b81526004018080602001828103825260328152602001806124776032913960400191505060405180910390fd5b6000610d4861197b565b9050610d58818787878787610f41565b60005b8451811015610e59576000858281518110610d7257fe5b602002602001015190506000858381518110610d8a57fe5b60200260200101519050610df7816040518060600160405280602a81526020016124a9602a91396001600086815260200190815260200160002060008d6001600160a01b03166001600160a01b031681526020019081526020016000205461197f9092919063ffffffff16565b60008381526001602090815260408083206001600160a01b038e811685529252808320939093558a1681522054610e2e9082611a16565b60009283526001602081815260408086206001600160a01b038d168752909152909320555001610d5b565b50846001600160a01b0316866001600160a01b0316826001600160a01b03167f4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb8787604051808060200180602001838103835285818151815260200191508051906020019060200280838360005b83811015610edf578181015183820152602001610ec7565b50505050905001838103825284818151815260200191508051906020019060200280838360005b83811015610f1e578181015183820152602001610f06565b5050505090500194505050505060405180910390a4610f41818787878787611a70565b505050505050565b600082815260046020526040902060020154610f6c90610f6761197b565b611263565b610fa75760405162461bcd60e51b815260040180806020018281038252602f815260200180612379602f913960400191505060405180910390fd5b610fb18282611cef565b5050565b610fbd61197b565b6001600160a01b0316816001600160a01b03161461100c5760405162461bcd60e51b815260040180806020018281038252602f81526020018061256e602f913960400191505060405180910390fd5b610fb18282611d58565b606081518351146110585760405162461bcd60e51b81526004018080602001828103825260298152602001806124fc6029913960400191505060405180910390fd5b6000835167ffffffffffffffff8111801561107257600080fd5b5060405190808252806020026020018201604052801561109c578160200160208202803683370190505b50905060005b84518110156110fa576110db8582815181106110ba57fe5b60200260200101518583815181106110ce57fe5b6020026020010151610a93565b8282815181106110e757fe5b60209081029190910101526001016110a2565b509392505050565b61110a61197b565b6001600160a01b031661111b611234565b6001600160a01b031614611176576040805162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e6572604482015290519081900360640190fd5b6005546040516000916001600160a01b0316907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0908390a3600580546001600160a01b0319169055565b6111ea7f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a633611263565b611228576040805162461bcd60e51b815260206004820152600a6024820152692727aa2fa6a4a72a22a960b11b604482015290519081900360640190fd5b610c3084848484611dc1565b6005546001600160a01b03165b90565b600082815260046020526040812061125c9083611ec2565b9392505050565b600082815260046020526040812061125c9083611ece565b600081565b816001600160a01b031661129261197b565b6001600160a01b031614156112d85760405162461bcd60e51b81526004018080602001828103825260298152602001806124d36029913960400191505060405180910390fd5b80600260006112e561197b565b6001600160a01b03908116825260208083019390935260409182016000908120918716808252919093529120805460ff19169215159290921790915561132961197b565b6001600160a01b03167f17307eab39ab6107e8899845ad3d59bd9653f200f220920489ca2b5937696c318360405180821515815260200191505060405180910390a35050565b6000818152600460205260408120610aff90611ee3565b7f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a681565b6000828152600460205260409020600201546113c890610f6761197b565b61100c5760405162461bcd60e51b81526004018080602001828103825260308152602001806124226030913960400191505060405180910390fd5b6001600160a01b03918216600090815260026020908152604080832093909416825291909152205460ff1690565b6001600160a01b0384166114765760405162461bcd60e51b81526004018080602001828103825260258152602001806124526025913960400191505060405180910390fd5b61147e61197b565b6001600160a01b0316856001600160a01b031614806114a457506114a485610cfe61197b565b6114df5760405162461bcd60e51b81526004018080602001828103825260298152602001806123f96029913960400191505060405180910390fd5b60006114e961197b565b90506115098187876114fa88611eee565b61150388611eee565b87610f41565b611550836040518060600160405280602a81526020016124a9602a913960008781526001602090815260408083206001600160a01b038d168452909152902054919061197f565b60008581526001602090815260408083206001600160a01b038b811685529252808320939093558716815220546115879084611a16565b60008581526001602090815260408083206001600160a01b03808b168086529184529382902094909455805188815291820187905280518a8416938616927fc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f6292908290030190a4610f41818787878787611f33565b61160461197b565b6001600160a01b0316611615611234565b6001600160a01b031614611670576040805162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e6572604482015290519081900360640190fd5b6001600160a01b0381166116b55760405162461bcd60e51b81526004018080602001828103825260268152602001806123d36026913960400191505060405180910390fd5b6005546040516001600160a01b038084169216907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a3600580546001600160a01b0319166001600160a01b0392909216919091179055565b600061125c836001600160a01b0384166120a4565b6001600160a01b03841661176b5760405162461bcd60e51b815260040180806020018281038252602181526020018061254d6021913960400191505060405180910390fd5b81518351146117ab5760405162461bcd60e51b81526004018080602001828103825260288152602001806125256028913960400191505060405180910390fd5b60006117b561197b565b90506117c681600087878787610f41565b60005b845181101561188a57611841600160008784815181106117e557fe5b602002602001015181526020019081526020016000206000886001600160a01b03166001600160a01b031681526020019081526020016000205485838151811061182b57fe5b6020026020010151611a1690919063ffffffff16565b6001600087848151811061185157fe5b602090810291909101810151825281810192909252604090810160009081206001600160a01b038b1682529092529020556001016117c9565b50846001600160a01b031660006001600160a01b0316826001600160a01b03167f4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb8787604051808060200180602001838103835285818151815260200191508051906020019060200280838360005b838110156119115781810151838201526020016118f9565b50505050905001838103825284818151815260200191508051906020019060200280838360005b83811015611950578181015183820152602001611938565b5050505090500194505050505060405180910390a461197481600087878787611a70565b5050505050565b3390565b60008184841115611a0e5760405162461bcd60e51b81526004018080602001828103825283818151815260200191508051906020019080838360005b838110156119d35781810151838201526020016119bb565b50505050905090810190601f168015611a005780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b505050900390565b60008282018381101561125c576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b611a82846001600160a01b03166120ee565b15610f4157836001600160a01b031663bc197c8187878686866040518663ffffffff1660e01b815260040180866001600160a01b03168152602001856001600160a01b03168152602001806020018060200180602001848103845287818151815260200191508051906020019060200280838360005b83811015611b10578181015183820152602001611af8565b50505050905001848103835286818151815260200191508051906020019060200280838360005b83811015611b4f578181015183820152602001611b37565b50505050905001848103825285818151815260200191508051906020019080838360005b83811015611b8b578181015183820152602001611b73565b50505050905090810190601f168015611bb85780820380516001836020036101000a031916815260200191505b5098505050505050505050602060405180830381600087803b158015611bdd57600080fd5b505af1925050508015611c0257506040513d6020811015611bfd57600080fd5b505160015b611c9757611c0e612255565b80611c195750611c60565b60405162461bcd60e51b81526020600482018181528351602484015283518493919283926044019190850190808383600083156119d35781810151838201526020016119bb565b60405162461bcd60e51b81526004018080602001828103825260348152602001806122fb6034913960400191505060405180910390fd5b6001600160e01b0319811663bc197c8160e01b14611ce65760405162461bcd60e51b81526004018080602001828103825260288152602001806123516028913960400191505060405180910390fd5b50505050505050565b6000828152600460205260409020611d079082611711565b15610fb157611d1461197b565b6001600160a01b0316816001600160a01b0316837f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45050565b6000828152600460205260409020611d7090826120f4565b15610fb157611d7d61197b565b6001600160a01b0316816001600160a01b0316837ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b60405160405180910390a45050565b6001600160a01b038416611e065760405162461bcd60e51b815260040180806020018281038252602181526020018061254d6021913960400191505060405180910390fd5b6000611e1061197b565b9050611e22816000876114fa88611eee565b60008481526001602090815260408083206001600160a01b0389168452909152902054611e4f9084611a16565b60008581526001602090815260408083206001600160a01b03808b16808652918452828520959095558151898152928301889052815190948616927fc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f6292908290030190a461197481600087878787611f33565b600061125c8383612109565b600061125c836001600160a01b03841661216d565b6000610aff82612185565b60408051600180825281830190925260609160009190602080830190803683370190505090508281600081518110611f2257fe5b602090810291909101015292915050565b611f45846001600160a01b03166120ee565b15610f4157836001600160a01b031663f23a6e6187878686866040518663ffffffff1660e01b815260040180866001600160a01b03168152602001856001600160a01b0316815260200184815260200183815260200180602001828103825283818151815260200191508051906020019080838360005b83811015611fd4578181015183820152602001611fbc565b50505050905090810190601f1680156120015780820380516001836020036101000a031916815260200191505b509650505050505050602060405180830381600087803b15801561202457600080fd5b505af192505050801561204957506040513d602081101561204457600080fd5b505160015b61205557611c0e612255565b6001600160e01b0319811663f23a6e6160e01b14611ce65760405162461bcd60e51b81526004018080602001828103825260288152602001806123516028913960400191505060405180910390fd5b60006120b0838361216d565b6120e657508154600181810184556000848152602080822090930184905584548482528286019093526040902091909155610aff565b506000610aff565b3b151590565b600061125c836001600160a01b038416612189565b8154600090821061214b5760405162461bcd60e51b815260040180806020018281038252602281526020018061232f6022913960400191505060405180910390fd5b82600001828154811061215a57fe5b9060005260206000200154905092915050565b60009081526001919091016020526040902054151590565b5490565b6000818152600183016020526040812054801561224557835460001980830191908101906000908790839081106121bc57fe5b90600052602060002001549050808760000184815481106121d957fe5b60009182526020808320909101929092558281526001898101909252604090209084019055865487908061220957fe5b60019003818190600052602060002001600090559055866001016000878152602001908152602001600020600090556001945050505050610aff565b6000915050610aff565b60e01c90565b600060443d101561226557611241565b600481823e6308c379a0612279825161224f565b1461228357611241565b6040513d600319016004823e80513d67ffffffffffffffff81602484011181841117156122b35750505050611241565b828401925082519150808211156122cd5750505050611241565b503d830160208284010111156122e557505050611241565b601f01601f191681016020016040529150509056fe455243313135353a207472616e7366657220746f206e6f6e2045524331313535526563656976657220696d706c656d656e746572456e756d657261626c655365743a20696e646578206f7574206f6620626f756e6473455243313135353a204552433131353552656365697665722072656a656374656420746f6b656e73416363657373436f6e74726f6c3a2073656e646572206d75737420626520616e2061646d696e20746f206772616e74455243313135353a2062616c616e636520717565727920666f7220746865207a65726f20616464726573734f776e61626c653a206e6577206f776e657220697320746865207a65726f2061646472657373455243313135353a2063616c6c6572206973206e6f74206f776e6572206e6f7220617070726f766564416363657373436f6e74726f6c3a2073656e646572206d75737420626520616e2061646d696e20746f207265766f6b65455243313135353a207472616e7366657220746f20746865207a65726f2061646472657373455243313135353a207472616e736665722063616c6c6572206973206e6f74206f776e6572206e6f7220617070726f766564455243313135353a20696e73756666696369656e742062616c616e636520666f72207472616e73666572455243313135353a2073657474696e6720617070726f76616c2073746174757320666f722073656c66455243313135353a206163636f756e747320616e6420696473206c656e677468206d69736d61746368455243313135353a2069647320616e6420616d6f756e7473206c656e677468206d69736d61746368455243313135353a206d696e7420746f20746865207a65726f2061646472657373416363657373436f6e74726f6c3a2063616e206f6e6c792072656e6f756e636520726f6c657320666f722073656c66a26469706673582212204ca3d62c8e88a881a4344db3db745ec36a395abb47b856ab8a5900c6b4cb907764736f6c634300070600334f776e61626c653a206e6577206f776e657220697320746865207a65726f20616464726573738be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0" +} \ No newline at end of file diff --git a/apps/api/src/app/contracts/bytecodes/UnlimitedSupplyToken.json b/apps/api/src/app/contracts/bytecodes/UnlimitedSupplyToken.json new file mode 100644 index 000000000..6177ffbbf --- /dev/null +++ b/apps/api/src/app/contracts/bytecodes/UnlimitedSupplyToken.json @@ -0,0 +1,3 @@ +{ + "bytecode": "0x60806040523480156200001157600080fd5b50604051620019f7380380620019f7833981810160405260608110156200003757600080fd5b81019080805160405193929190846401000000008211156200005857600080fd5b9083019060208201858111156200006e57600080fd5b82516401000000008111828201881017156200008957600080fd5b82525081516020918201929091019080838360005b83811015620000b85781810151838201526020016200009e565b50505050905090810190601f168015620000e65780820380516001836020036101000a031916815260200191505b50604052602001805160405193929190846401000000008211156200010a57600080fd5b9083019060208201858111156200012057600080fd5b82516401000000008111828201881017156200013b57600080fd5b82525081516020918201929091019080838360005b838110156200016a57818101518382015260200162000150565b50505050905090810190601f168015620001985780820380516001836020036101000a031916815260200191505b5060405260209081015185519093508592508491620001bd91600391850190620004a9565b508051620001d3906004906020840190620004a9565b50506005805460ff19166012179055506000620001ef62000279565b600780546001600160a01b0319166001600160a01b03831690811790915560405191925090600090600080516020620019d7833981519152908290a35062000237816200027d565b6200024460008262000388565b620002707f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a68262000388565b50505062000555565b3390565b6200028762000279565b6001600160a01b03166200029a62000398565b6001600160a01b031614620002f6576040805162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e6572604482015290519081900360640190fd5b6001600160a01b0381166200033d5760405162461bcd60e51b8152600401808060200182810382526026815260200180620019b16026913960400191505060405180910390fd5b6007546040516001600160a01b03808416921690600080516020620019d783398151915290600090a3600780546001600160a01b0319166001600160a01b0392909216919091179055565b620003948282620003a7565b5050565b6007546001600160a01b031690565b6000828152600660209081526040909120620003ce91839062000b1d62000422821b17901c565b156200039457620003de62000279565b6001600160a01b0316816001600160a01b0316837f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45050565b600062000439836001600160a01b03841662000442565b90505b92915050565b600062000450838362000491565b62000488575081546001818101845560008481526020808220909301849055845484825282860190935260409020919091556200043c565b5060006200043c565b60009081526001919091016020526040902054151590565b828054600181600116156101000203166002900490600052602060002090601f016020900481019282620004e157600085556200052c565b82601f10620004fc57805160ff19168380011785556200052c565b828001600101855582156200052c579182015b828111156200052c5782518255916020019190600101906200050f565b506200053a9291506200053e565b5090565b5b808211156200053a57600081556001016200053f565b61144c80620005656000396000f3fe608060405234801561001057600080fd5b506004361061014d5760003560e01c80638da5cb5b116100c3578063a9059cbb1161007c578063a9059cbb146103fd578063ca15c87314610429578063d539139314610446578063d547741f1461044e578063dd62ed3e1461047a578063f2fde38b146104a85761014d565b80638da5cb5b1461034e5780639010d07c1461037257806391d148541461039557806395d89b41146103c1578063a217fddf146103c9578063a457c2d7146103d15761014d565b80632f2ff15d116101155780632f2ff15d1461027c578063313ce567146102aa57806336568abe146102c857806339509351146102f457806370a0823114610320578063715018a6146103465761014d565b806306fdde0314610152578063095ea7b3146101cf57806318160ddd1461020f57806323b872dd14610229578063248a9ca31461025f575b600080fd5b61015a6104ce565b6040805160208082528351818301528351919283929083019185019080838360005b8381101561019457818101518382015260200161017c565b50505050905090810190601f1680156101c15780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6101fb600480360360408110156101e557600080fd5b506001600160a01b038135169060200135610564565b604080519115158252519081900360200190f35b610217610582565b60408051918252519081900360200190f35b6101fb6004803603606081101561023f57600080fd5b506001600160a01b03813581169160208101359091169060400135610588565b6102176004803603602081101561027557600080fd5b503561060f565b6102a86004803603604081101561029257600080fd5b50803590602001356001600160a01b0316610624565b005b6102b2610690565b6040805160ff9092168252519081900360200190f35b6102a8600480360360408110156102de57600080fd5b50803590602001356001600160a01b0316610699565b6101fb6004803603604081101561030a57600080fd5b506001600160a01b0381351690602001356106fa565b6102176004803603602081101561033657600080fd5b50356001600160a01b0316610748565b6102a8610763565b610356610821565b604080516001600160a01b039092168252519081900360200190f35b6103566004803603604081101561038857600080fd5b5080359060200135610830565b6101fb600480360360408110156103ab57600080fd5b50803590602001356001600160a01b031661084f565b61015a610867565b6102176108c8565b6101fb600480360360408110156103e757600080fd5b506001600160a01b0381351690602001356108cd565b6101fb6004803603604081101561041357600080fd5b506001600160a01b038135169060200135610935565b6102176004803603602081101561043f57600080fd5b5035610949565b610217610960565b6102a86004803603604081101561046457600080fd5b50803590602001356001600160a01b0316610984565b6102176004803603604081101561049057600080fd5b506001600160a01b03813581169160200135166109dd565b6102a8600480360360208110156104be57600080fd5b50356001600160a01b0316610a08565b60038054604080516020601f600260001961010060018816150201909516949094049384018190048102820181019092528281526060939092909183018282801561055a5780601f1061052f5761010080835404028352916020019161055a565b820191906000526020600020905b81548152906001019060200180831161053d57829003601f168201915b5050505050905090565b6000610578610571610b32565b8484610b36565b5060015b92915050565b60025490565b6000610595848484610c22565b610605846105a1610b32565b61060085604051806060016040528060288152602001611352602891396001600160a01b038a166000908152600160205260408120906105df610b32565b6001600160a01b031681526020810191909152604001600020549190610d7d565b610b36565b5060019392505050565b60009081526006602052604090206002015490565b60008281526006602052604090206002015461064790610642610b32565b61084f565b6106825760405162461bcd60e51b815260040180806020018281038252602f815260200180611285602f913960400191505060405180910390fd5b61068c8282610e14565b5050565b60055460ff1690565b6106a1610b32565b6001600160a01b0316816001600160a01b0316146106f05760405162461bcd60e51b815260040180806020018281038252602f8152602001806113e8602f913960400191505060405180910390fd5b61068c8282610e7d565b6000610578610707610b32565b846106008560016000610718610b32565b6001600160a01b03908116825260208083019390935260409182016000908120918c168152925290205490610ee6565b6001600160a01b031660009081526020819052604090205490565b61076b610b32565b6001600160a01b031661077c610821565b6001600160a01b0316146107d7576040805162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e6572604482015290519081900360640190fd5b6007546040516000916001600160a01b0316907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0908390a3600780546001600160a01b0319169055565b6007546001600160a01b031690565b60008281526006602052604081206108489083610f40565b9392505050565b60008281526006602052604081206108489083610f4c565b60048054604080516020601f600260001961010060018816150201909516949094049384018190048102820181019092528281526060939092909183018282801561055a5780601f1061052f5761010080835404028352916020019161055a565b600081565b60006105786108da610b32565b84610600856040518060600160405280602581526020016113c36025913960016000610904610b32565b6001600160a01b03908116825260208083019390935260409182016000908120918d16815292529020549190610d7d565b6000610578610942610b32565b8484610c22565b600081815260066020526040812061057c90610f61565b7f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a681565b6000828152600660205260409020600201546109a290610642610b32565b6106f05760405162461bcd60e51b81526004018080602001828103825260308152602001806113226030913960400191505060405180910390fd5b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b610a10610b32565b6001600160a01b0316610a21610821565b6001600160a01b031614610a7c576040805162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e6572604482015290519081900360640190fd5b6001600160a01b038116610ac15760405162461bcd60e51b81526004018080602001828103825260268152602001806112b46026913960400191505060405180910390fd5b6007546040516001600160a01b038084169216907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a3600780546001600160a01b0319166001600160a01b0392909216919091179055565b6000610848836001600160a01b038416610f6c565b3390565b6001600160a01b038316610b7b5760405162461bcd60e51b815260040180806020018281038252602481526020018061139f6024913960400191505060405180910390fd5b6001600160a01b038216610bc05760405162461bcd60e51b81526004018080602001828103825260228152602001806112da6022913960400191505060405180910390fd5b6001600160a01b03808416600081815260016020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b6001600160a01b038316610c675760405162461bcd60e51b815260040180806020018281038252602581526020018061137a6025913960400191505060405180910390fd5b6001600160a01b038216610cac5760405162461bcd60e51b81526004018080602001828103825260238152602001806112626023913960400191505060405180910390fd5b610cb7838383610fb6565b610cf4816040518060600160405280602681526020016112fc602691396001600160a01b0386166000908152602081905260409020549190610d7d565b6001600160a01b038085166000908152602081905260408082209390935590841681522054610d239082610ee6565b6001600160a01b038084166000818152602081815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b60008184841115610e0c5760405162461bcd60e51b81526004018080602001828103825283818151815260200191508051906020019080838360005b83811015610dd1578181015183820152602001610db9565b50505050905090810190601f168015610dfe5780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b505050900390565b6000828152600660205260409020610e2c9082610b1d565b1561068c57610e39610b32565b6001600160a01b0316816001600160a01b0316837f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45050565b6000828152600660205260409020610e959082610ff4565b1561068c57610ea2610b32565b6001600160a01b0316816001600160a01b0316837ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b60405160405180910390a45050565b600082820183811015610848576040805162461bcd60e51b815260206004820152601b60248201527f536166654d6174683a206164646974696f6e206f766572666c6f770000000000604482015290519081900360640190fd5b60006108488383611009565b6000610848836001600160a01b03841661106d565b600061057c82611085565b6000610f78838361106d565b610fae5750815460018181018455600084815260208082209093018490558454848252828601909352604090209190915561057c565b50600061057c565b610fe07f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a68461084f565b15610fef57610fef8382611089565b505050565b6000610848836001600160a01b038416611179565b8154600090821061104b5760405162461bcd60e51b81526004018080602001828103825260228152602001806112406022913960400191505060405180910390fd5b82600001828154811061105a57fe5b9060005260206000200154905092915050565b60009081526001919091016020526040902054151590565b5490565b6001600160a01b0382166110e4576040805162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015290519081900360640190fd5b6110f060008383610fb6565b6002546110fd9082610ee6565b6002556001600160a01b0382166000908152602081905260409020546111239082610ee6565b6001600160a01b0383166000818152602081815260408083209490945583518581529351929391927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9281900390910190a35050565b6000818152600183016020526040812054801561123557835460001980830191908101906000908790839081106111ac57fe5b90600052602060002001549050808760000184815481106111c957fe5b6000918252602080832090910192909255828152600189810190925260409020908401905586548790806111f957fe5b6001900381819060005260206000200160009055905586600101600087815260200190815260200160002060009055600194505050505061057c565b600091505061057c56fe456e756d657261626c655365743a20696e646578206f7574206f6620626f756e647345524332303a207472616e7366657220746f20746865207a65726f2061646472657373416363657373436f6e74726f6c3a2073656e646572206d75737420626520616e2061646d696e20746f206772616e744f776e61626c653a206e6577206f776e657220697320746865207a65726f206164647265737345524332303a20617070726f766520746f20746865207a65726f206164647265737345524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e6365416363657373436f6e74726f6c3a2073656e646572206d75737420626520616e2061646d696e20746f207265766f6b6545524332303a207472616e7366657220616d6f756e74206578636565647320616c6c6f77616e636545524332303a207472616e736665722066726f6d20746865207a65726f206164647265737345524332303a20617070726f76652066726f6d20746865207a65726f206164647265737345524332303a2064656372656173656420616c6c6f77616e63652062656c6f77207a65726f416363657373436f6e74726f6c3a2063616e206f6e6c792072656e6f756e636520726f6c657320666f722073656c66a2646970667358221220499bdba29a0efc1e2ec89ff121b70b1e41ed57eeac384ddb3eb294c7b8a6682164736f6c634300070600334f776e61626c653a206e6577206f776e657220697320746865207a65726f20616464726573738be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0" +} \ No newline at end of file diff --git a/apps/api/src/app/contracts/index.ts b/apps/api/src/app/contracts/index.ts new file mode 100644 index 000000000..dfb6e16c3 --- /dev/null +++ b/apps/api/src/app/contracts/index.ts @@ -0,0 +1,120 @@ +import { AbiItem } from 'web3-utils'; +import { ContractNetworksConfig } from '@safe-global/protocol-kit'; + +import Launchpad from './abis/Launchpad.json'; +import VotingEscrow from './abis/VotingEscrow.json'; +import RewardDistributor from './abis/RewardDistributor.json'; +import SmartWalletWhitelist from './abis/SmartWalletWhitelist.json'; +import RewardFaucet from './abis/RewardFaucet.json'; +import LensReward from './abis/LensReward.json'; +import BalMinter from './abis/BalancerMinter.json'; +import BPT from './abis/BPT.json'; +import BPTGauge from './abis/BPTGauge.json'; +import BAL from './abis/BAL.json'; +import THX from './abis/THX.json'; +import USDC from './abis/USDC.json'; +import BalancerVault from './abis/BalancerVault.json'; +import BalancerGaugeController from './abis/BalancerGaugeController.json'; +import THXRegistry from './abis/THXRegistry.json'; +import THXPaymentSplitter from './abis/THXPaymentSplitter.json'; + +export const contractNetworks = { + '31337': { + // Safe + safeMasterCopyAddress: '0xC44951780f195Ed71145e3d0d2F25726A097C348', + safeProxyFactoryAddress: '0x1122fD9eBB2a8E7c181Cc77705d2B4cA5D72988A', + multiSendAddress: '0x7E4728eFfC9376CC7C0EfBCc779cC9833D83a984', + multiSendCallOnlyAddress: '0x75Cbb6C4Db4Bb4f6F8D5F56072A6cF4Bf4C5413C', + fallbackHandlerAddress: '0x5D3D550Da6678C0444F5D77Ca086678D9CdeEecA', + signMessageLibAddress: '0x658FAD2acB6d1E615f295E566ee9a6d32Cc97b10', + createCallAddress: '0x40Efd8a16485213445E6d8b9a4266Fd2dFf7C69a', + simulateTxAccessorAddress: '0xFF1eE64b8806C0891e8F73b37f8403F441b552E1', + + // Tokens + THX: '0xc368fA6A4057BcFD9E49221d8354d5fA6B88945a', + USDC: '0x439F0128d07f005e0703602f366599ACaaBfEA18', + BAL: '0x24E91C3a2822bDc4bc73512872ab07fD93c8101b', + BPT: '0x76aBe9ec9b15947ba1Ca910695B8b6CffeD8E6CA', + BPTGauge: '0x7Cb8d1EAd6303C079c501e93F3ba28C227cd7000', + BalancerVault: '0xb3B2b0fc5ce12aE58EEb13E19547Eb2Dd61A79D5', + + // veTHX + VotingEscrow: '0xFB1aEd47351005cE1BF85a339C019bea96BC9a21', + RewardDistributor: '0x1BBfB8A870823D52Ed42F4Ad0706f37F32C229a7', + RewardFaucet: '0xF880B1920Eb6c1A45162900059150D22faFc2070', + SmartWalletWhitelist: '0x36260689483bc55753E3258725f31E8aee31A7B0', + LensReward: '0x8c4Ca9343734227366653495cC068aB03B4f5bee', + + // Company + THXRegistry: '0x726C9c8278eC209CfBE6BBb1d02e65dF6FcB7cdA', + THXPaymentSplitter: '0x50861908E2Bb609524D63F5b4E57d3CACaDf09C2', + CompanyMultiSig: '0xaf9d56684466fcFcEA0a2B7fC137AB864d642946', + }, + '137': { + // Tokens + BPT: '0xb204BF10bc3a5435017D3db247f56dA601dFe08A', + BPTGauge: '0xf16BECC1Bcaf0fF0b865024a644a4da1A2f8585c', + BalancerVault: '0xBA12222222228d8Ba445958a75a0704d566BF2C8', + BAL: '0x9a71012B13CA4d3D0Cdc72A177DF3ef03b0E76A3', + USDC: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + THX: '0x2934b36ca9A4B31E633C5BE670C8C8b28b6aA015', + + // veTHX + VotingEscrow: '0xE3B8E734e7BCcB64B63e032795896CC57012A51D', + RewardDistributor: '0xCc62c812EfF9cA4c35623103B2Bb63E22f465E09', + RewardFaucet: '0xA1D7671f73FbcB5e079d4dC4Cffb7dDD0967EA7E', + SmartWalletWhitelist: '0x876625a92cEAa7f1Bddd40908B8eb5C6080cB83C', + LensReward: '0xE8D9624E0B7f839540E7c13577550E3Eff3FC8aA', + + // Company + THXRegistry: '', + THXPaymentSplitter: '', + CompanyMultiSig: '0x0b8e0aAF940cc99EDA5DA5Ab0a8d6Ed798eDc08A', + }, + '1': { + BalancerGaugeController: '0xC128468b7Ce63eA702C1f104D55A2566b13D3ABD', + BalancerRootGauge: '0x9902913ce5439d667774c8f9526064b2bc103b4a', + }, +} as ContractNetworksConfig & any; + +export const contractArtifacts: { [contractName: string]: { abi: any; bytecode: string } } = { + RewardFaucet, + RewardDistributor, + SmartWalletWhitelist, + Launchpad, + LensReward, + BalMinter, + VotingEscrow, + BalancerVault, + BalancerGaugeController, + BPT, + BPTGauge, + USDC, + THX, + BAL, + THXRegistry, + THXPaymentSplitter, +}; + +export const contractNames = ['BalancerVault'] as const; +export const tokenContractNames = [ + 'LimitedSupplyToken', + 'UnlimitedSupplyToken', + 'NonFungibleToken', + 'UnlimitedSupplyToken', + 'THX_ERC1155', + 'VotingEscrow', + 'BPT', + 'BPTGauge', + 'USDC', + 'THX', + 'BAL', + 'THXPaymentSplitter', +] as const; +export type TokenContractName = (typeof tokenContractNames)[number]; + +export interface ContractConfig { + address: string; + abi: AbiItem[]; + bytecode: string; +} diff --git a/apps/api/src/app/controllers/account/account.router.ts b/apps/api/src/app/controllers/account/account.router.ts new file mode 100644 index 000000000..003598405 --- /dev/null +++ b/apps/api/src/app/controllers/account/account.router.ts @@ -0,0 +1,68 @@ +import express from 'express'; +import { assertRequestInput, guard } from '@thxnetwork/api/middlewares'; + +import RouterWallet from './wallet/wallets.router'; + +// Account +import * as ReadAccount from './get.controller'; +import * as UpdateAccount from './patch.controller'; +import * as DeleteAccount from './delete.controller'; + +// Social OAuth +import * as CreateAccountDisconnect from './disconnect/post.controller'; + +import * as ReadAccountDiscord from './discord/get.controller'; +import * as GetAccountByDiscordId from './discord/get.by-discord-id.controller'; +import * as CreateTwitterTweet from './twitter/tweet/post.controller'; +import * as CreateTwitterUser from './twitter/user/post.controller'; +import * as CreateTwitterSearch from './twitter/search/post.controller'; +import * as CreateTwitterUserByUsername from './twitter/user/by/username/post.controller'; + +const router: express.Router = express.Router(); + +router.use('/wallets', RouterWallet); + +router.get('/', guard.check(['account:read']), ReadAccount.controller); +router.patch('/', guard.check(['account:read', 'account:write']), UpdateAccount.controller); +router.delete('/', guard.check(['account:write']), DeleteAccount.controller); + +router.post( + '/disconnect', + guard.check(['account:read', 'account:write']), + assertRequestInput(CreateAccountDisconnect.validation), + CreateAccountDisconnect.controller, +); + +router.get('/discord', guard.check(['account:read']), ReadAccountDiscord.controller); +router.get( + '/discord/:discordId', + guard.check(['account:read']), + assertRequestInput(GetAccountByDiscordId.validation), + GetAccountByDiscordId.controller, +); +router.post( + '/twitter/tweet', + assertRequestInput(CreateTwitterTweet.validation), + guard.check(['account:read']), + CreateTwitterTweet.controller, +); +router.post( + '/twitter/user/', + assertRequestInput(CreateTwitterUser.validation), + guard.check(['account:read']), + CreateTwitterUser.controller, +); +router.post( + '/twitter/search/', + assertRequestInput(CreateTwitterSearch.validation), + guard.check(['account:read']), + CreateTwitterSearch.controller, +); +router.post( + '/twitter/user/by/username', + assertRequestInput(CreateTwitterUserByUsername.validation), + guard.check(['account:read']), + CreateTwitterUserByUsername.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/account/delete.controller.ts b/apps/api/src/app/controllers/account/delete.controller.ts new file mode 100644 index 000000000..7a051c137 --- /dev/null +++ b/apps/api/src/app/controllers/account/delete.controller.ts @@ -0,0 +1,9 @@ +import { Response, Request } from 'express'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; + +const controller = async (req: Request, res: Response) => { + await AccountProxy.remove(req.auth.sub); + + res.status(204).end(); +}; +export { controller }; diff --git a/apps/api/src/app/controllers/account/disconnect/post.controller.ts b/apps/api/src/app/controllers/account/disconnect/post.controller.ts new file mode 100644 index 000000000..6b69a1810 --- /dev/null +++ b/apps/api/src/app/controllers/account/disconnect/post.controller.ts @@ -0,0 +1,13 @@ +import { Response, Request } from 'express'; +import { body } from 'express-validator'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; + +const validation = [body('kind').isString()]; + +const controller = async (req: Request, res: Response) => { + const account = await AccountProxy.findById(req.auth.sub); + await AccountProxy.disconnect(account, req.body.kind); + + res.end(); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/account/discord/get.by-discord-id.controller.ts b/apps/api/src/app/controllers/account/discord/get.by-discord-id.controller.ts new file mode 100644 index 000000000..5be5f6a9f --- /dev/null +++ b/apps/api/src/app/controllers/account/discord/get.by-discord-id.controller.ts @@ -0,0 +1,15 @@ +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { Wallet } from '@thxnetwork/api/models/Wallet'; + +const validation = [param('discordId')]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['Account'] + const account = await AccountProxy.getByDiscordId(req.params.discordId); + const wallets = await Wallet.find({ sub: account.sub }); + + res.json({ account, wallets }); +}; +export { validation, controller }; diff --git a/apps/api/src/app/controllers/account/discord/get.controller.ts b/apps/api/src/app/controllers/account/discord/get.controller.ts new file mode 100644 index 000000000..52c260ea4 --- /dev/null +++ b/apps/api/src/app/controllers/account/discord/get.controller.ts @@ -0,0 +1,19 @@ +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import DiscordDataProxy from '@thxnetwork/api/proxies/DiscordDataProxy'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { AccessTokenKind, OAuthDiscordScope } from '@thxnetwork/common/enums'; +import { Request, Response } from 'express'; + +const controller = async (req: Request, res: Response) => { + const account = await AccountProxy.findById(req.auth.sub); + const token = await AccountProxy.getToken(account, AccessTokenKind.Discord, [ + OAuthDiscordScope.Identify, + OAuthDiscordScope.Guilds, + ]); + if (!token) throw new NotFoundError('Discord token not found.'); + + const guilds = await DiscordDataProxy.getGuilds(token); + + res.json({ guilds }); +}; +export { controller }; diff --git a/apps/api/src/app/controllers/account/get.controller.ts b/apps/api/src/app/controllers/account/get.controller.ts new file mode 100644 index 000000000..e1083e583 --- /dev/null +++ b/apps/api/src/app/controllers/account/get.controller.ts @@ -0,0 +1,39 @@ +import { Request, Response } from 'express'; +import { WalletVariant, AccountVariant } from '@thxnetwork/common/enums'; +import { getChainId } from '@thxnetwork/api/services/ContractService'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import WalletService from '@thxnetwork/api/services/WalletService'; +import THXService from '@thxnetwork/api/services/THXService'; + +const validation = []; + +const controller = async (req: Request, res: Response) => { + const account = await AccountProxy.findById(req.auth.sub); + + // Connect identity if none exists + await THXService.connect(account); + + // Remove actual tokens from response + account.tokens = account.tokens.map(({ kind, userId, scopes, metadata }) => ({ + kind, + userId, + scopes, + metadata, + })) as TToken[]; + + // If account variant is metamask and no wallet is found then create it + if (account.variant === AccountVariant.Metamask) { + const wallet = await WalletService.findOne({ sub: req.auth.sub, variant: WalletVariant.WalletConnect }); + if (!wallet) { + await WalletService.createWalletConnect({ + sub: req.auth.sub, + address: account.address, + chainId: getChainId(), + }); + } + } + + res.json(account); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/account/patch.controller.ts b/apps/api/src/app/controllers/account/patch.controller.ts new file mode 100644 index 000000000..d9804e943 --- /dev/null +++ b/apps/api/src/app/controllers/account/patch.controller.ts @@ -0,0 +1,9 @@ +import { Request, Response } from 'express'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; + +const controller = async (req: Request, res: Response) => { + const account = await AccountProxy.update(req.auth.sub, req.body); + res.json(account); +}; + +export { controller }; diff --git a/apps/api/src/app/controllers/account/twitter/search/post.controller.ts b/apps/api/src/app/controllers/account/twitter/search/post.controller.ts new file mode 100644 index 000000000..91cb4f4cc --- /dev/null +++ b/apps/api/src/app/controllers/account/twitter/search/post.controller.ts @@ -0,0 +1,17 @@ +import { TwitterQuery } from '@thxnetwork/common/twitter'; +import { Request, Response } from 'express'; +import { body } from 'express-validator'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import TwitterDataProxy from '@thxnetwork/api/proxies/TwitterDataProxy'; + +const validation = [body('operators').customSanitizer((ops) => TwitterQuery.parse(ops))]; + +const controller = async (req: Request, res: Response) => { + const account = await AccountProxy.findById(req.auth.sub); + const query = TwitterQuery.create(req.body.operators); + const posts = await TwitterDataProxy.search(account, query); + + res.json(posts); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/account/twitter/tweet/post.controller.ts b/apps/api/src/app/controllers/account/twitter/tweet/post.controller.ts new file mode 100644 index 000000000..0a18d7fe1 --- /dev/null +++ b/apps/api/src/app/controllers/account/twitter/tweet/post.controller.ts @@ -0,0 +1,15 @@ +import { Request, Response } from 'express'; +import { body } from 'express-validator'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import TwitterDataProxy from '@thxnetwork/api/proxies/TwitterDataProxy'; + +const validation = [body('tweetId').isString()]; + +const controller = async (req: Request, res: Response) => { + const account = await AccountProxy.findById(req.auth.sub); + const tweet = await TwitterDataProxy.getTweet(account, req.body.tweetId); + + res.json(tweet); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/account/twitter/user/by/username/post.controller.ts b/apps/api/src/app/controllers/account/twitter/user/by/username/post.controller.ts new file mode 100644 index 000000000..54c11fe52 --- /dev/null +++ b/apps/api/src/app/controllers/account/twitter/user/by/username/post.controller.ts @@ -0,0 +1,15 @@ +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import TwitterDataProxy from '@thxnetwork/api/proxies/TwitterDataProxy'; +import { Request, Response } from 'express'; +import { body } from 'express-validator'; + +const validation = [body('username').isString()]; + +const controller = async (req: Request, res: Response) => { + const account = await AccountProxy.findById(req.auth.sub); + const user = await TwitterDataProxy.getUserByUsername(account, req.body.username); + + res.json(user); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/account/twitter/user/post.controller.ts b/apps/api/src/app/controllers/account/twitter/user/post.controller.ts new file mode 100644 index 000000000..56149d6f0 --- /dev/null +++ b/apps/api/src/app/controllers/account/twitter/user/post.controller.ts @@ -0,0 +1,15 @@ +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import TwitterDataProxy from '@thxnetwork/api/proxies/TwitterDataProxy'; +import { Request, Response } from 'express'; +import { body } from 'express-validator'; + +const validation = [body('userId').isString()]; + +const controller = async (req: Request, res: Response) => { + const account = await AccountProxy.findById(req.auth.sub); + const user = await TwitterDataProxy.getUser(account, req.body.userId); + + res.json(user); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/account/wallet/confirm/post.controller.ts b/apps/api/src/app/controllers/account/wallet/confirm/post.controller.ts new file mode 100644 index 000000000..942713c10 --- /dev/null +++ b/apps/api/src/app/controllers/account/wallet/confirm/post.controller.ts @@ -0,0 +1,28 @@ +import { Request, Response } from 'express'; +import { body, query } from 'express-validator'; +import { BadRequestError, ForbiddenError } from '@thxnetwork/api/util/errors'; +import { Transaction } from '@thxnetwork/api/models'; +import SafeService from '@thxnetwork/api/services/SafeService'; + +const validation = [ + body('chainId').isNumeric(), + body('safeTxHash').isString(), + body('signature').isString(), + query('walletId').isMongoId(), +]; + +const controller = async (req: Request, res: Response) => { + const walletId = req.query.walletId as string; + const wallet = await SafeService.findById(walletId); + if (!wallet) throw new BadRequestError('Wallet not found.'); + if (wallet.sub !== req.auth.sub) throw new ForbiddenError('Wallet not owned by sub.'); + + const tx = await Transaction.findOne({ safeTxHash: req.body.safeTxHash }); + if (!tx) throw new BadRequestError('Transaction not found.'); + + await SafeService.confirm(wallet, req.body.safeTxHash, req.body.signature); + + res.json(wallet); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/account/wallet/list.controller.ts b/apps/api/src/app/controllers/account/wallet/list.controller.ts new file mode 100644 index 000000000..5727a0583 --- /dev/null +++ b/apps/api/src/app/controllers/account/wallet/list.controller.ts @@ -0,0 +1,15 @@ +import { Request, Response } from 'express'; +import { query } from 'express-validator'; +import WalletService from '@thxnetwork/api/services/WalletService'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; + +const validation = [query('chainId').optional().isNumeric()]; + +const controller = async (req: Request, res: Response) => { + const account = await AccountProxy.findById(req.auth.sub); + const wallets = await WalletService.list(account); + + res.json(wallets); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/account/wallet/post.controller.ts b/apps/api/src/app/controllers/account/wallet/post.controller.ts new file mode 100644 index 000000000..f34782890 --- /dev/null +++ b/apps/api/src/app/controllers/account/wallet/post.controller.ts @@ -0,0 +1,26 @@ +import { Request, Response } from 'express'; +import { recoverSigner } from '@thxnetwork/api/util/network'; +import { body } from 'express-validator'; +import WalletService from '@thxnetwork/api/services/WalletService'; + +const validation = [ + body('variant').isString(), + body('message').optional().isString(), + body('signature').optional().isString(), +]; + +const controller = async (req: Request, res: Response) => { + const { message, signature, variant } = req.body; + const data: Partial = { sub: req.auth.sub }; + + // If no message and signature are present prepare a wallet to connect later + if (signature && message) { + data.address = recoverSigner(message, signature); + } + + await WalletService.create(variant, data); + + res.status(201).end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/account/wallet/wallets.router.ts b/apps/api/src/app/controllers/account/wallet/wallets.router.ts new file mode 100644 index 000000000..e07041a04 --- /dev/null +++ b/apps/api/src/app/controllers/account/wallet/wallets.router.ts @@ -0,0 +1,23 @@ +import express from 'express'; +import * as ListWallets from './list.controller'; +import * as CreateWallets from './post.controller'; +import * as CreateWalletConfirm from './confirm/post.controller'; +import { assertRequestInput, guard } from '@thxnetwork/api/middlewares'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get('/', guard.check(['account:read']), assertRequestInput(ListWallets.validation), ListWallets.controller); +router.post( + '/', + guard.check(['account:read', 'account:write']), + assertRequestInput(CreateWallets.validation), + CreateWallets.controller, +); +router.post( + '/confirm', + guard.check(['account:read', 'account:write']), + assertRequestInput(CreateWalletConfirm.validation), + CreateWalletConfirm.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/account/wallet/wallets.test.ts b/apps/api/src/app/controllers/account/wallet/wallets.test.ts new file mode 100644 index 000000000..983a9186d --- /dev/null +++ b/apps/api/src/app/controllers/account/wallet/wallets.test.ts @@ -0,0 +1,86 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { widgetAccessToken, sub, userWalletPrivateKey4 } from '@thxnetwork/api/util/jest/constants'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { ChainId, WalletVariant } from '@thxnetwork/common/enums'; +import { signMessage } from '@thxnetwork/api/util/jest/network'; +import { safeVersion } from '@thxnetwork/api/services/ContractService'; + +const user = request.agent(app); + +describe('Account Wallets', () => { + beforeAll(() => beforeAllCallback({ skipWalletCreation: true })); + afterAll(afterAllCallback); + + describe('GET /wallets', () => { + it('HTTP 200 if OK', (done) => { + user.get(`/v1/account/wallets`) + .set({ Authorization: widgetAccessToken }) + .expect((res: request.Response) => { + expect(res.body.length).toEqual(0); + }) + .expect(200, done); + }); + }); + + // Create Safe Wallet + describe('POST /wallets (Safe)', () => { + it('HTTP 200 if OK', (done) => { + const message = 'test'; + const signature = signMessage(userWalletPrivateKey4, message); + user.post(`/v1/account/wallets`) + .set({ Authorization: widgetAccessToken }) + .send({ + variant: WalletVariant.Safe, + message, + signature, + }) + .expect(201, done); + }); + }); + + describe('POST /wallets (WalletConnect)', () => { + it('HTTP 200 if OK', (done) => { + const message = 'test'; + const signature = signMessage(userWalletPrivateKey4, message); + user.post(`/v1/account/wallets`) + .set({ Authorization: widgetAccessToken }) + .send({ + variant: WalletVariant.WalletConnect, + message, + signature, + }) + .expect(201, done); + }); + }); + + // Create WebConnect wallet + + describe('GET /wallets', () => { + it('HTTP 200 if OK', (done) => { + user.get('/v1/account/wallets') + .set({ Authorization: widgetAccessToken }) + .expect((res: request.Response) => { + expect(res.body.length).toEqual(2); + + const safe = res.body.find((wallet: any) => wallet.variant === WalletVariant.Safe); + const wallet = res.body.find((wallet: any) => wallet.variant === WalletVariant.WalletConnect); + expect(safe.sub).toEqual(sub); + expect(safe.chainId).toEqual(ChainId.Hardhat); + expect(safe.variant).toBe(WalletVariant.Safe); + expect(safe.address).toBeDefined(); + expect(safe.safeVersion).toBe(safeVersion); + + expect(wallet.sub).toEqual(sub); + expect(wallet.chainId).toEqual(ChainId.Hardhat); + expect(wallet.variant).toBe(WalletVariant.WalletConnect); + expect(wallet.address).toBeDefined(); + }) + .expect(200, done); + }); + }); + + describe('POST /wallets', () => { + // + }); +}); diff --git a/apps/api/src/app/controllers/brands/brands.router.ts b/apps/api/src/app/controllers/brands/brands.router.ts new file mode 100644 index 000000000..5f46aaca6 --- /dev/null +++ b/apps/api/src/app/controllers/brands/brands.router.ts @@ -0,0 +1,20 @@ +import express from 'express'; +import { assertRequestInput, checkJwt, corsHandler, guard } from '@thxnetwork/api/middlewares'; + +import * as GetBrand from './get.controller'; +import * as PutBrand from './put.controller'; + +const router: express.Router = express.Router(); +router.get('/', GetBrand.controller); + +router + .use(checkJwt) + .use(corsHandler) + .put( + '/', + guard.check(['brands:write', 'brands:read']), + assertRequestInput(PutBrand.validation), + PutBrand.controller, + ); + +export default router; diff --git a/apps/api/src/app/controllers/brands/get.controller.ts b/apps/api/src/app/controllers/brands/get.controller.ts new file mode 100644 index 000000000..24452b312 --- /dev/null +++ b/apps/api/src/app/controllers/brands/get.controller.ts @@ -0,0 +1,9 @@ +import { Request, Response } from 'express'; +import BrandService from '@thxnetwork/api/services/BrandService'; + +const controller = async (req: Request, res: Response) => { + const brand = await BrandService.get(req.header('X-PoolId')); + res.json(brand); +}; + +export { controller }; diff --git a/apps/api/src/app/controllers/brands/put.controller.ts b/apps/api/src/app/controllers/brands/put.controller.ts new file mode 100644 index 000000000..2bf9a077b --- /dev/null +++ b/apps/api/src/app/controllers/brands/put.controller.ts @@ -0,0 +1,34 @@ +import { isValidUrl } from '@thxnetwork/api/util/url'; +import { Request, Response } from 'express'; +import { body } from 'express-validator'; +import { ForbiddenError } from '@thxnetwork/api/util/errors'; +import BrandService from '../../services/BrandService'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import CanvasService from '@thxnetwork/api/services/CanvasService'; +import ImageService from '@thxnetwork/api/services/ImageService'; + +const validation = [ + body('logoImgUrl').custom((logoImgUrl?: string) => { + return logoImgUrl && logoImgUrl.length ? isValidUrl(logoImgUrl) : true; + }), + body('backgroundImgUrl').custom((backgroundImgUrl?: string) => { + return backgroundImgUrl && backgroundImgUrl.length ? isValidUrl(backgroundImgUrl) : true; + }), +]; +const controller = async (req: Request, res: Response) => { + const poolId = req.header('X-PoolId'); + const hasAccess = await PoolService.isSubjectAllowed(req.auth.sub, poolId); + if (!hasAccess) throw new ForbiddenError('Not your pool'); + + // Update logo and bg changes + const { logoImgUrl, backgroundImgUrl } = req.body; + const brand = await BrandService.update({ poolId }, { logoImgUrl, backgroundImgUrl }); + + // Create campaign preview + const previewFile = await CanvasService.createPreviewImage(brand); + brand.previewImgUrl = await ImageService.uploadToS3(previewFile, `${poolId}_campaign_preview.png`, 'image/*'); + + res.json(await brand.save()); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/client/client.router.ts b/apps/api/src/app/controllers/client/client.router.ts new file mode 100644 index 000000000..791575cd0 --- /dev/null +++ b/apps/api/src/app/controllers/client/client.router.ts @@ -0,0 +1,19 @@ +import express from 'express'; +import * as ListController from './list.controller'; +import * as PostController from './post.controller'; +import * as PatchController from './patch.controller'; +import { assertPoolAccess, assertRequestInput, guard } from '@thxnetwork/api/middlewares'; + +const router: express.Router = express.Router(); + +router.get('/', guard.check(['clients:read']), assertPoolAccess, ListController.controller); +router.patch( + '/:id', + guard.check(['clients:read', 'clients:write']), + assertRequestInput(PatchController.validation), + assertPoolAccess, + PatchController.controller, +); +router.post('/', guard.check(['clients:read', 'clients:write']), assertPoolAccess, PostController.controller); + +export default router; diff --git a/apps/api/src/app/controllers/client/list.controller.ts b/apps/api/src/app/controllers/client/list.controller.ts new file mode 100644 index 000000000..a9c38d26c --- /dev/null +++ b/apps/api/src/app/controllers/client/list.controller.ts @@ -0,0 +1,16 @@ +import { Request, Response } from 'express'; +import { Client } from '@thxnetwork/api/models/Client'; +import ClientProxy from '@thxnetwork/api/proxies/ClientProxy'; + +const controller = async (req: Request, res: Response) => { + const poolId = req.header('X-PoolId'); + const clients = await Client.find({ poolId }); + const promises = clients.map(async (client) => { + return await ClientProxy.getCredentials(client.toJSON()); + }); + const result = await Promise.all(promises); + + res.status(200).json(result); +}; + +export { controller }; diff --git a/apps/api/src/app/controllers/client/patch.controller.ts b/apps/api/src/app/controllers/client/patch.controller.ts new file mode 100644 index 000000000..87787cc65 --- /dev/null +++ b/apps/api/src/app/controllers/client/patch.controller.ts @@ -0,0 +1,15 @@ +import ClientProxy from '@thxnetwork/api/proxies/ClientProxy'; +import { Request, Response } from 'express'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { body, param } from 'express-validator'; + +const validation = [param('id').exists(), body('name').exists()]; +const controller = async (req: Request, res: Response) => { + let client = await ClientProxy.get(req.params.id); + if (!client) throw new NotFoundError('Cannot found Client ID in this request'); + + client = await ClientProxy.update(client.clientId, { name: req.body['name'] }); + res.status(200).json(client); +}; + +export { validation, controller }; diff --git a/apps/api/src/app/controllers/client/post.controller.ts b/apps/api/src/app/controllers/client/post.controller.ts new file mode 100644 index 000000000..81cc6a8c2 --- /dev/null +++ b/apps/api/src/app/controllers/client/post.controller.ts @@ -0,0 +1,34 @@ +import { Request, Response } from 'express'; +import { GrantVariant } from '@thxnetwork/common/enums'; +import { widgetScopes } from '@thxnetwork/api/util/jest/constants'; +import ClientProxy from '@thxnetwork/api/proxies/ClientProxy'; + +const controller = async (req: Request, res: Response) => { + const poolId = req.header('X-PoolId'); + const { grantType, redirectUri, requestUri, name } = req.body; + const grantMap = { + [GrantVariant.AuthorizationCode]: { + application_type: 'web', + grant_types: [grantType], + request_uris: [requestUri], + redirect_uris: [redirectUri], + post_logout_redirect_uris: [requestUri], + response_types: ['code'], + scope: widgetScopes, + }, + [GrantVariant.ClientCredentials]: { + application_type: 'web', + grant_types: [grantType], + request_uris: [], + redirect_uris: [], + response_types: [], + scope: 'openid events:write identities:read identities:write pools:write pools:read', + }, + }; + const payload = grantMap[grantType]; + const client = await ClientProxy.create(req.auth.sub, poolId, payload, name); + + res.json(client); +}; + +export { controller }; diff --git a/apps/api/src/app/controllers/coupons/coupons.router.ts b/apps/api/src/app/controllers/coupons/coupons.router.ts new file mode 100644 index 000000000..75aece92d --- /dev/null +++ b/apps/api/src/app/controllers/coupons/coupons.router.ts @@ -0,0 +1,22 @@ +import { assertRequestInput, guard } from '@thxnetwork/api/middlewares'; +import express from 'express'; +import * as ListCouponCode from './list.controller'; +import * as RemoveCouponCode from './delete.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get( + '/', + guard.check(['coupon_rewards:read']), + assertRequestInput(ListCouponCode.validation), + ListCouponCode.controller, +); + +router.delete( + '/:couponCodeId/', + guard.check(['coupon_rewards:write']), + assertRequestInput(RemoveCouponCode.validation), + RemoveCouponCode.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/coupons/delete.controller.ts b/apps/api/src/app/controllers/coupons/delete.controller.ts new file mode 100644 index 000000000..a2f0f93af --- /dev/null +++ b/apps/api/src/app/controllers/coupons/delete.controller.ts @@ -0,0 +1,30 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { CouponCode } from '@thxnetwork/api/models/CouponCode'; +import { RewardCouponPayment } from '@thxnetwork/api/models/RewardCouponPayment'; +import { ForbiddenError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { RewardVariant } from '@thxnetwork/common/enums'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import RewardService from '@thxnetwork/api/services/RewardService'; + +const validation = [param('couponCodeId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const couponCode = await CouponCode.findById(req.params.couponCodeId); + if (!couponCode) throw new NotFoundError('Coupon code not found'); + + // Check if user is allowed to access the pool for this couponRewardId + const reward = await RewardService.findById(RewardVariant.Coupon, couponCode.couponRewardId); + const isAllowed = await PoolService.isSubjectAllowed(req.auth.sub, reward.poolId); + if (!isAllowed) throw new ForbiddenError('Not allowed to access these coupon codes'); + + // Check if the coupon code has already been purchased + const payment = await RewardCouponPayment.exists({ couponCodeId: req.params.couponCodeId }); + if (payment) throw new ForbiddenError('Coupon code has been redeemed'); + + await CouponCode.findByIdAndDelete(req.params.couponCodeId); + + res.status(204).end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/coupons/list.controller.ts b/apps/api/src/app/controllers/coupons/list.controller.ts new file mode 100644 index 000000000..2136f2717 --- /dev/null +++ b/apps/api/src/app/controllers/coupons/list.controller.ts @@ -0,0 +1,32 @@ +import { Request, Response } from 'express'; +import { query } from 'express-validator'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import { RewardVariant } from '@thxnetwork/common/enums'; +import RewardService from '@thxnetwork/api/services/RewardService'; +import { ForbiddenError } from '@thxnetwork/api/util/errors'; + +const validation = [ + query('couponRewardId').isMongoId(), + query('page').isInt(), + query('limit').isInt(), + query('query').isString(), +]; + +const controller = async (req: Request, res: Response) => { + const couponRewardId = req.query.couponRewardId as string; + const page = Number(req.query.page); + const limit = Number(req.query.limit); + const query = req.query.query as string; + console.log(query); + + // Check if user is allowed to access the pool for this couponRewardId + const reward = await RewardService.findById(RewardVariant.Coupon, couponRewardId); + const isAllowed = await PoolService.isSubjectAllowed(req.auth.sub, reward.poolId); + if (!isAllowed) throw new ForbiddenError('Not allowed to access these coupon codes'); + + const result = await PoolService.findCouponCodes({ couponRewardId, query }, page, limit); + + res.json(result); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/data/data.router.ts b/apps/api/src/app/controllers/data/data.router.ts new file mode 100644 index 000000000..facda5c9a --- /dev/null +++ b/apps/api/src/app/controllers/data/data.router.ts @@ -0,0 +1,35 @@ +import express, { Request, Response } from 'express'; +import axios, { AxiosRequestConfig, Method } from 'axios'; +import { MIXPANEL_API_URL } from '@thxnetwork/api/config/secrets'; +import { ForbiddenError } from '@thxnetwork/api/util/errors'; +// import { getIP } from '@thxnetwork/api/util/ip'; + +const router: express.Router = express.Router(); + +const mixpanelProxy = function (options: AxiosRequestConfig) { + if (!options.url.startsWith('/')) throw new ForbiddenError(); + axios.defaults.baseURL = MIXPANEL_API_URL; + return axios(options); +}; + +router.all('*', async (req: Request, res: Response) => { + // if (req.body.data) { + // const dataDecoded = Buffer.from(req.body.data, 'base64').toString(); + // const dataObject = JSON.parse(dataDecoded); + // const data = dataObject.map((item) => { + // if (!item || !item.event) return item; + // return { properties: { ...item.properties, real_ip: getIP(req) } }; + // }); + // const dataString = JSON.stringify(data); + // req.body.data = Buffer.from(dataString).toString('base64'); + // } + await mixpanelProxy({ + method: req.method as Method, + url: req.originalUrl.replace(req.baseUrl, ''), + headers: { 'X-REAL-IP': req.ip }, + params: req.body, + }); + res.end(); +}); + +export default router; diff --git a/apps/api/src/app/controllers/earn/earn.router.ts b/apps/api/src/app/controllers/earn/earn.router.ts new file mode 100644 index 000000000..9993a31fd --- /dev/null +++ b/apps/api/src/app/controllers/earn/earn.router.ts @@ -0,0 +1,10 @@ +import express from 'express'; +import * as ListPriceController from './prices/list.controller'; +import * as ListAPRController from './metrics/list.controller'; + +const router: express.Router = express.Router(); + +router.get('/prices', ListPriceController.controller); +router.get('/metrics', ListAPRController.controller); + +export default router; diff --git a/apps/api/src/app/controllers/earn/metrics/list.controller.ts b/apps/api/src/app/controllers/earn/metrics/list.controller.ts new file mode 100644 index 000000000..ec1f02bb6 --- /dev/null +++ b/apps/api/src/app/controllers/earn/metrics/list.controller.ts @@ -0,0 +1,16 @@ +import BalancerService from '@thxnetwork/api/services/BalancerService'; +import WalletService from '@thxnetwork/api/services/WalletService'; +import { ChainId } from '@thxnetwork/common/enums'; +import { Request, Response } from 'express'; +import { query } from 'express-validator'; + +const validation = [query('walletId').optional().isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const wallet = await WalletService.findById(req.query.walletId as string); + const result = BalancerService.getMetrics(wallet ? wallet.chainId : ChainId.Polygon); + + res.json(result); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/earn/prices/list.controller.ts b/apps/api/src/app/controllers/earn/prices/list.controller.ts new file mode 100644 index 000000000..28304a0d0 --- /dev/null +++ b/apps/api/src/app/controllers/earn/prices/list.controller.ts @@ -0,0 +1,9 @@ +import BalancerService from '@thxnetwork/api/services/BalancerService'; +import { Request, Response } from 'express'; + +const controller = async (req: Request, res: Response) => { + const pricing = BalancerService.getPricing(); + res.json(pricing); +}; + +export { controller }; diff --git a/apps/api/src/app/controllers/erc1155/delete.controller.ts b/apps/api/src/app/controllers/erc1155/delete.controller.ts new file mode 100644 index 000000000..bb6a20f6d --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/delete.controller.ts @@ -0,0 +1,16 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { ForbiddenError } from '@thxnetwork/api/util/errors'; +import { ERC1155 } from '@thxnetwork/api/models/ERC1155'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const erc1155 = await ERC1155.findById(req.params.id); + if (erc1155.sub !== req.auth.sub) throw new ForbiddenError('Not your ERC1155'); + + await erc1155.deleteOne(); + + return res.status(204).end(); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc1155/erc1155.router.ts b/apps/api/src/app/controllers/erc1155/erc1155.router.ts new file mode 100644 index 000000000..90c2f465c --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/erc1155.router.ts @@ -0,0 +1,99 @@ +import express from 'express'; +import { assertPoolAccess, assertRequestInput, guard } from '@thxnetwork/api/middlewares'; +import { upload } from '@thxnetwork/api/util/multer'; + +import * as ReadERC1155 from './get.controller'; +import * as ListERC1155 from './list.controller'; +import * as RemoveERC1155 from './delete.controller'; +import * as ListERC1155Metadata from './metadata/list.controller'; +import * as ListERC1155Token from './token/list.controller'; +import * as ReadERC1155Token from './token/get.controller'; +import * as CreateERC1155 from './post.controller'; +import * as CreateERC1155Metadata from './metadata/post.controller'; + +import * as UpdateERC1155 from './patch.controller'; +import * as ReadERC1155Metadata from './metadata/get.controller'; +import * as PatchERC1155Metadata from './metadata/patch.controller'; +import * as DeleteERC1155Metadata from './metadata/delete.controller'; +import * as ImportERC1155Contract from './import/post.controller'; +import * as PreviewERC1155Contract from './import/preview/post.controller'; +import * as CreateERC1155Transfer from './transfer/post.controller'; + +const router: express.Router = express.Router(); + +router.get( + '/token', + guard.check(['erc1155:read']), + assertRequestInput(ListERC1155Token.validation), + ListERC1155Token.controller, +); +router.get('/token/:id', guard.check(['erc1155:read']), ReadERC1155Token.controller); +router.get('/', guard.check(['erc1155:read']), assertRequestInput(ListERC1155.validation), ListERC1155.controller); +router.get('/:id', guard.check(['erc1155:read']), assertRequestInput(ReadERC1155.validation), ReadERC1155.controller); + +router.post( + '/', + upload.single('file'), + guard.check(['erc1155:read', 'erc1155:write']), + assertRequestInput(CreateERC1155.validation), + CreateERC1155.controller, +); +router.post( + '/import', + ImportERC1155Contract.controller, + assertPoolAccess, + assertRequestInput(ImportERC1155Contract.validation), +); +router.post('/preview', assertRequestInput(PreviewERC1155Contract.validation), PreviewERC1155Contract.controller); +router.patch( + '/:id/metadata/:metadataId', + guard.check(['erc1155:write']), + assertRequestInput(PatchERC1155Metadata.validation), + PatchERC1155Metadata.controller, +); + +router.delete( + '/:id/metadata/:metadataId', + guard.check(['erc1155:write']), + assertRequestInput(DeleteERC1155Metadata.validation), + DeleteERC1155Metadata.controller, +); + +router.get('/:id/metadata', guard.check(['erc1155:read']), ListERC1155Metadata.controller); + +router.post( + '/:id/metadata/', + guard.check(['erc1155:write']), + assertRequestInput(CreateERC1155Metadata.validation), + CreateERC1155Metadata.controller, +); + +router.patch( + '/:id', + guard.check(['erc1155:write', 'erc1155:read']), + assertRequestInput(UpdateERC1155.validation), + UpdateERC1155.controller, +); + +router.get( + '/:id/metadata/:metadataId', + guard.check(['erc1155:read']), + ReadERC1155Metadata.controller, + assertRequestInput(ReadERC1155Metadata.validation), +); + +router.post( + '/transfer', + // guard.check(['erc1155_transfer:read', 'erc1155_transfer:write']), + assertRequestInput(CreateERC1155Transfer.validation), + CreateERC1155Transfer.controller, +); + +router.delete( + '/:id', + guard.check(['erc1155:read', 'erc1155:write']), + assertRequestInput(RemoveERC1155.validation), + RemoveERC1155.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/erc1155/erc1155.test.ts b/apps/api/src/app/controllers/erc1155/erc1155.test.ts new file mode 100644 index 000000000..b222e7714 --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/erc1155.test.ts @@ -0,0 +1,89 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { ChainId } from '@thxnetwork/common/enums'; +import { isAddress } from 'web3-utils'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { dashboardAccessToken } from '@thxnetwork/api/util/jest/constants'; +import { createImage } from '@thxnetwork/api/util/jest/images'; + +const user = request.agent(app); + +describe('ERC1155', () => { + const chainId = ChainId.Hardhat, + name = 'Planets of the Galaxy', + description = 'Collection full of rarities.'; + let erc1155ID: string; + + beforeAll(beforeAllCallback); + afterAll(afterAllCallback); + + describe('POST /erc1155', () => { + it('should create and return contract details', async () => { + const logoImg = createImage(); + await user + .post('/v1/erc1155') + .set('Authorization', dashboardAccessToken) + .attach('file', logoImg, { filename: 'logoImg.jpg', contentType: 'image/jpg' }) + .field({ + chainId, + name, + description, + }) + .expect(({ body }: request.Response) => { + expect(body._id).toBeDefined(); + expect(body.chainId).toBe(chainId); + expect(body.name).toBe(name); + expect(body.description).toBe(description); + expect(isAddress(body.address)).toBe(true); + expect(body.logoImgUrl).toBeDefined(); + erc1155ID = body._id; + }) + .expect(201); + }); + }); + + describe('GET /erc1155/:id', () => { + it('should return contract details', (done) => { + user.get('/v1/erc1155/' + erc1155ID) + .set('Authorization', dashboardAccessToken) + .send() + .expect(({ body }: request.Response) => { + expect(body.chainId).toBe(chainId); + expect(body.name).toBe(name); + expect(body.description).toBe(description); + expect(isAddress(body.address)).toBe(true); + expect(body.logoImgUrl).toBeDefined(); + }) + .expect(200, done); + }); + it('should 400 for invalid ID', (done) => { + user.get('/v1/erc1155/' + 'invalid_id') + .set('Authorization', dashboardAccessToken) + .send() + .expect(({ body }: request.Response) => { + expect(body.errors[0].msg).toContain('Invalid value'); + }) + .expect(400, done); + }); + it('should 404 if not known', (done) => { + user.get('/v1/erc1155/' + '62397f69760ac5f9ab4454df') + .set('Authorization', dashboardAccessToken) + .send() + .expect(({ body }: request.Response) => { + expect(body.error.message).toContain('Not Found'); + }) + .expect(404, done); + }); + describe('PATCH /erc1155/:id', () => { + it('should update a created token', (done) => { + user.patch('/v1/erc1155/' + erc1155ID) + .set('Authorization', dashboardAccessToken) + .send() + .expect(({ body }: request.Response) => { + expect(body).toBeDefined(); + }) + .expect(200, done); + }); + }); + }); +}); diff --git a/apps/api/src/app/controllers/erc1155/get.controller.ts b/apps/api/src/app/controllers/erc1155/get.controller.ts new file mode 100644 index 000000000..943de3d44 --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/get.controller.ts @@ -0,0 +1,21 @@ +import { param } from 'express-validator'; +import { Request, Response } from 'express'; +import ERC1155Service from '@thxnetwork/api/services/ERC1155Service'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + let erc1155 = await ERC1155Service.findById(req.params.id); + + if (!erc1155) throw new NotFoundError(); + // Check if pending transaction is mined. + if (!erc1155.address) erc1155 = await ERC1155Service.queryDeployTransaction(erc1155); + // Still no address. + if (!erc1155.address) return res.send(erc1155); + const owner = await erc1155.contract.methods.owner().call(); + + res.json({ ...erc1155.toJSON(), owner }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc1155/import/erc1155-import.test.ts b/apps/api/src/app/controllers/erc1155/import/erc1155-import.test.ts new file mode 100644 index 000000000..ff0bcc7cb --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/import/erc1155-import.test.ts @@ -0,0 +1,123 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { ChainId } from '@thxnetwork/common/enums'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { dashboardAccessToken, sub } from '@thxnetwork/api/util/jest/constants'; +import { ERC1155TokenState } from '@thxnetwork/common/enums'; +import { ERC1155Document } from '@thxnetwork/api/models/ERC1155'; +import { alchemy } from '@thxnetwork/api/util/alchemy'; +import { deployERC1155, mockGetNftsForOwner } from '@thxnetwork/api/util/jest/erc1155'; +import { PoolDocument } from '@thxnetwork/api/models'; +import { Contract } from 'web3-eth-contract'; +import { getProvider } from '@thxnetwork/api/util/network'; +import TransactionService from '@thxnetwork/api/services/TransactionService'; +import { ethers } from 'ethers'; + +const user = request.agent(app); + +describe('ERC1155 import', () => { + let erc1155: ERC1155Document, pool: PoolDocument, nftContract: Contract; + const chainId = ChainId.Hardhat, + nftName = 'Test Collection'; + + beforeAll(beforeAllCallback); + afterAll(afterAllCallback); + + describe('POST /pools', () => { + it('HTTP 201', (done) => { + user.post('/v1/pools') + .set('Authorization', dashboardAccessToken) + .send({ chainId }) + .expect((res: request.Response) => { + pool = res.body; + }) + .expect(201, done); + }); + }); + + describe('POST /erc1155/import', () => { + it('HTTP 201`', async () => { + // Create 1 NFT collection + nftContract = await deployERC1155(); + const id = 1; + const amount = 1; + // Mint 1 token in the collection + await TransactionService.sendAsync( + nftContract.options.address, + nftContract.methods.mint(pool.safeAddress, id, amount, ethers.constants.HashZero), + chainId, + ); + + // Mock Alchemy SDK return value for getNftsForOwner + jest.spyOn(alchemy.nft, 'getNftsForOwner').mockImplementation(() => + Promise.resolve(mockGetNftsForOwner(nftContract.options.address) as any), + ); + + // Run the import for the deployed contract address + await user + .post('/v1/erc1155/import') + .set({ 'Authorization': dashboardAccessToken, 'X-PoolId': pool._id }) + .send({ chainId, contractAddress: nftContract.options.address, name: nftName }) + .expect(({ body }: request.Response) => { + expect(body.erc1155._id).toBeDefined(); + expect(body.erc1155.address).toBe(nftContract.options.address); + + erc1155 = body.erc1155; + }) + .expect(201); + }); + }); + + describe('GET /erc1155/:id', () => { + const { defaultAccount } = getProvider(chainId); + + it('HTTP 200', (done) => { + user.get(`/v1/erc1155/${erc1155._id}`) + .set('Authorization', dashboardAccessToken) + .send() + .expect(({ body }: request.Response) => { + expect(body.chainId).toBe(chainId); + expect(body.sub).toBe(sub); + expect(body.name).toBe(nftName); + expect(body.address).toBe(nftContract.options.address); + expect(body.owner).toBe(defaultAccount); + }) + .expect(200, done); + }); + }); + + describe('GET /erc1155/token', () => { + it('HTTP 200', (done) => { + user.get(`/v1/erc1155/token`) + .query({ walletId: pool.safe._id }) + .set('Authorization', dashboardAccessToken) + .send() + .expect(({ body }: request.Response) => { + expect(body.length).toBe(1); + expect(body[0].sub).toBe(sub); + expect(body[0].erc1155Id).toBe(erc1155._id); + expect(body[0].state).toBe(ERC1155TokenState.Minted); + expect(body[0].recipient).toBe(pool.safeAddress); + expect(body[0].tokenUri).toBeDefined(); + expect(body[0].tokenId).toBeDefined(); + expect(body[0].metadataId).toBeDefined(); + }) + .expect(200, done); + }); + }); + + describe('GET /erc1155/:id/metadata', () => { + it('HTTP 200', (done) => { + user.get(`/v1/erc1155/${erc1155._id}/metadata`) + .set('Authorization', dashboardAccessToken) + .send() + .expect(({ body }: request.Response) => { + expect(body.total).toBe(1); + expect(body.results[0].name).toBeDefined(); + expect(body.results[0].description).toBeDefined(); + expect(body.results[0].image).toBeDefined(); + }) + .expect(200, done); + }); + }); +}); diff --git a/apps/api/src/app/controllers/erc1155/import/post.controller.ts b/apps/api/src/app/controllers/erc1155/import/post.controller.ts new file mode 100644 index 000000000..c07ab9e5c --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/import/post.controller.ts @@ -0,0 +1,104 @@ +import { body } from 'express-validator'; +import { Request, Response } from 'express'; +import { ERC1155Token } from '@thxnetwork/api/models/ERC1155Token'; +import { ERC1155 } from '@thxnetwork/api/models/ERC1155'; +import { ForbiddenError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { ERC1155TokenState } from '@thxnetwork/common/enums'; +import { getNFTsForOwner, parseIPFSImageUrl } from '@thxnetwork/api/util/alchemy'; +import { ChainId, NFTVariant } from '@thxnetwork/common/enums'; +import { logger } from '@thxnetwork/api/util/logger'; +import { ERC1155Metadata } from '@thxnetwork/api/models/ERC1155Metadata'; +import { toChecksumAddress } from 'web3-utils'; +import PoolService from '@thxnetwork/api/services/PoolService'; + +const validation = [body('contractAddress').exists().isString(), body('chainId').exists().isNumeric()]; + +const controller = async (req: Request, res: Response) => { + const chainId = Number(req.body.chainId) as ChainId; + const contractAddress = toChecksumAddress(req.body.contractAddress); + const pool = await PoolService.getById(req.header('X-PoolId')); + const ownedNfts = await getNFTsForOwner(pool.safeAddress, contractAddress); + if (!ownedNfts.length) throw new NotFoundError('Could not find NFT tokens for this contract address'); + + let erc1155 = await ERC1155.findOne({ + sub: req.auth.sub, + chainId, + address: contractAddress, + }); + + // If erc1155 already exists check if it is owned by the authenticated user + if (erc1155 && erc1155.sub !== req.auth.sub) { + throw new ForbiddenError('This is not your contract.'); + } + + // If erc1155 is owned or not existing continue with update or upsert + erc1155 = await ERC1155.findOneAndUpdate( + { + chainId, + sub: req.auth.sub, + address: contractAddress, + }, + { + chainId, + sub: req.auth.sub, + address: contractAddress, + variant: NFTVariant.ERC1155, + name: req.body.name, + archived: false, + baseURL: '', + }, + { upsert: true, new: true }, + ); + const erc1155Tokens = await Promise.all( + ownedNfts.map(async ({ name, description, image, collection, tokenId, tokenUri }) => { + try { + const erc1155Id = String(erc1155._id); + const imageUrl = parseIPFSImageUrl(image.originalUrl); + const metadata = await ERC1155Metadata.findOneAndUpdate( + { + erc1155Id, + tokenId, + }, + { + erc1155Id, + tokenId, + name, + description, + imageUrl, + image: imageUrl, + externalUrl: collection.externalUrl, + }, + { upsert: true, new: true }, + ); + const walletId = String(pool.safe._id); + const erc1155Token = await ERC1155Token.findOneAndUpdate( + { + erc1155Id, + tokenId, + sub: req.auth.sub, + recipient: pool.safeAddress, + }, + { + erc1155Id, + tokenId, + walletId, + tokenUri, + sub: req.auth.sub, + recipient: pool.safeAddress, + state: ERC1155TokenState.Minted, + metadataId: String(metadata._id), + }, + { upsert: true, new: true }, + ); + + return { ...erc1155Token.toJSON(), metadata: metadata.toJSON() }; + } catch (error) { + logger.error(error); + } + }), + ); + + res.status(201).json({ erc1155, erc1155Tokens }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc1155/import/preview/post.controller.ts b/apps/api/src/app/controllers/erc1155/import/preview/post.controller.ts new file mode 100644 index 000000000..d365f80e3 --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/import/preview/post.controller.ts @@ -0,0 +1,22 @@ +import { Request, Response } from 'express'; +import { body } from 'express-validator'; +import { getNFTsForOwner, parseIPFSImageUrl } from '@thxnetwork/api/util/alchemy'; + +const validation = [body('address').exists().isString(), body('contractAddress').exists().isString()]; + +const controller = async (req: Request, res: Response) => { + const ownedNFTs = await getNFTsForOwner(req.body.address, req.body.contractAddress); + res.status(200).json( + ownedNFTs.map((nft) => { + return { + balance: nft.balance, + name: nft.name, + description: nft.description, + tokenId: nft.tokenId, + tokenUri: nft.tokenUri, + image: parseIPFSImageUrl(nft.image.originalUrl), + }; + }), + ); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc1155/list.controller.ts b/apps/api/src/app/controllers/erc1155/list.controller.ts new file mode 100644 index 000000000..a26f607c8 --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/list.controller.ts @@ -0,0 +1,13 @@ +import { Request, Response } from 'express'; +import { ERC1155Document } from '@thxnetwork/api/models/ERC1155'; +import ERC1155Service from '@thxnetwork/api/services/ERC1155Service'; + +const validation = []; + +const controller = async (req: Request, res: Response) => { + const result = await ERC1155Service.findBySub(req.auth.sub); + + res.json(result.map((erc1155: ERC1155Document) => erc1155._id)); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc1155/metadata/delete.controller.ts b/apps/api/src/app/controllers/erc1155/metadata/delete.controller.ts new file mode 100644 index 000000000..a6fc60555 --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/metadata/delete.controller.ts @@ -0,0 +1,13 @@ +import { param } from 'express-validator'; +import { Request, Response } from 'express'; +import ERC1155Service from '@thxnetwork/api/services/ERC1155Service'; + +const validation = [param('metadataId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['ERC1155 Metadata'] + await ERC1155Service.deleteMetadata(req.params.metadataId); + res.status(200).json({ success: true }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc1155/metadata/get.controller.ts b/apps/api/src/app/controllers/erc1155/metadata/get.controller.ts new file mode 100644 index 000000000..323cf385c --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/metadata/get.controller.ts @@ -0,0 +1,13 @@ +import { param } from 'express-validator'; +import { Request, Response } from 'express'; +import ERC1155Service from '@thxnetwork/api/services/ERC1155Service'; + +const validation = [param('id').isMongoId(), param('metadataId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['ERC1155 Metadata'] + const metadata = await ERC1155Service.findMetadataById(req.params.metadataId); + res.json(metadata); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc1155/metadata/list.controller.ts b/apps/api/src/app/controllers/erc1155/metadata/list.controller.ts new file mode 100644 index 000000000..8735e715c --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/metadata/list.controller.ts @@ -0,0 +1,25 @@ +import { Request, Response } from 'express'; +import ERC1155Service from '@thxnetwork/api/services/ERC1155Service'; +import { param, query } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; + +const validation = [ + param('id').isMongoId(), + query('limit').optional().isInt({ gt: 0 }), + query('page').optional().isInt({ gt: 0 }), +]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['ERC1155'] + const erc1155 = await ERC1155Service.findById(req.params.id); + if (!erc1155) throw new NotFoundError('Could not find this NFT in the database'); + + const result = await ERC1155Service.findMetadataByNFT( + req.params.id, + req.query.page ? Number(req.query.page) : null, + req.query.limit ? Number(req.query.limit) : null, + ); + res.json(result); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc1155/metadata/patch.controller.ts b/apps/api/src/app/controllers/erc1155/metadata/patch.controller.ts new file mode 100644 index 000000000..b4367c831 --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/metadata/patch.controller.ts @@ -0,0 +1,34 @@ +import { Request, Response } from 'express'; +import { body, param } from 'express-validator'; + +import ERC1155Service from '@thxnetwork/api/services/ERC1155Service'; +import { BadRequestError, NotFoundError } from '@thxnetwork/api/util/errors'; + +const validation = [ + param('id').isMongoId(), + param('metadataId').isMongoId(), + body('title').optional().isString().isLength({ min: 0, max: 100 }), + body('description').optional().isString().isLength({ min: 0, max: 400 }), + body('attributes').exists(), +]; + +const controller = async (req: Request, res: Response) => { + const erc1155 = await ERC1155Service.findById(req.params.id); + if (!erc1155) throw new NotFoundError('Could not find this NFT in the database'); + + const metadata = await ERC1155Service.findMetadataById(req.params.metadataId); + if (!metadata) throw new NotFoundError('Could not find this NFT Metadata in the database'); + + const tokens = metadata.tokens || []; + if (tokens.length) throw new BadRequestError('There token minted with this metadata'); + + await metadata.updateOne({ + title: req.body.title, + description: req.body.description, + attributes: req.body.attributes, + }); + + res.json({ ...metadata.toJSON(), tokens }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc1155/metadata/post.controller.ts b/apps/api/src/app/controllers/erc1155/metadata/post.controller.ts new file mode 100644 index 000000000..70b1658a4 --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/metadata/post.controller.ts @@ -0,0 +1,52 @@ +import { Request, Response } from 'express'; +import { body, param } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { ERC1155Metadata } from '@thxnetwork/api/models/ERC1155Metadata'; +import { IPFS_BASE_URL, NODE_ENV } from '@thxnetwork/api/config/secrets'; +import { ERC1155Token } from '@thxnetwork/api/models/ERC1155Token'; +import ERC1155Service from '@thxnetwork/api/services/ERC1155Service'; +import IPFSService from '@thxnetwork/api/services/IPFSService'; + +const validation = [ + param('id').isMongoId(), + body('name').optional().isString(), + body('imageUrl').optional().isURL(), + body('description').optional().isString(), + body('externalUrl').optional().isURL(), +]; + +const controller = async (req: Request, res: Response) => { + const erc1155 = await ERC1155Service.findById(req.params.id); + if (!erc1155) throw new NotFoundError('Could not find this NFT in the database'); + + let image = req.body.imageUrl; + + if (req.body.imageUrl && NODE_ENV === 'production') { + const cid = await IPFSService.addUrlSource(req.body.imageUrl); + image = IPFS_BASE_URL + cid; + } + + const erc1155Id = String(erc1155._id); + const count = await ERC1155Metadata.countDocuments({ erc1155Id }); + const tokenId = count + 1; + const metadata = await ERC1155Metadata.create({ + erc1155Id, + name: req.body.name, + image, + imageUrl: req.body.imageUrl, + description: req.body.description, + externalUrl: req.body.externalUrl, + tokenId, + }); + + // Should also create token + await ERC1155Token.create({ + sub: req.auth.sub, + erc1155Id, + metadatId: metadata._id, + tokenId, + }); + + res.status(201).json(metadata); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc1155/patch.controller.ts b/apps/api/src/app/controllers/erc1155/patch.controller.ts new file mode 100644 index 000000000..88d871ec3 --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/patch.controller.ts @@ -0,0 +1,17 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { ForbiddenError, NotFoundError } from '@thxnetwork/api/util/errors'; +import ERC1155Service from '@thxnetwork/api/services/ERC1155Service'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['ERC1155'] + const erc1155 = await ERC1155Service.findById(req.params.id); + if (!erc1155) throw new NotFoundError('Could not find the token for this id'); + if (erc1155.sub !== req.auth.sub) throw new ForbiddenError('Not your ERC721'); + + const result = await ERC1155Service.update(erc1155, req.body); + return res.json(result); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc1155/post.controller.ts b/apps/api/src/app/controllers/erc1155/post.controller.ts new file mode 100644 index 000000000..2224d6c6c --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/post.controller.ts @@ -0,0 +1,37 @@ +import { Request, Response } from 'express'; +import { body, check, query } from 'express-validator'; +import { NFTVariant } from '@thxnetwork/common/enums'; +import ERC1155Service from '@thxnetwork/api/services/ERC1155Service'; +import ImageService from '@thxnetwork/api/services/ImageService'; + +const validation = [ + body('name').exists().isString(), + body('description').exists().isString(), + body('chainId').exists().isNumeric(), + check('file') + .optional() + .custom((value, { req }) => { + return ['jpg', 'jpeg', 'gif', 'png'].includes(req.file.mimetype); + }), + query('forceSync').optional().isBoolean(), +]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['ERC1155'] + const logoImgUrl = req.file && (await ImageService.upload(req.file)); + const forceSync = req.query.forceSync !== undefined ? req.query.forceSync === 'true' : false; + const erc1155 = await ERC1155Service.deploy( + { + variant: NFTVariant.ERC1155, + sub: req.auth.sub, + chainId: req.body.chainId, + name: req.body.name, + description: req.body.description, + logoImgUrl, + }, + forceSync, + ); + res.status(201).json(erc1155); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc1155/token/get.controller.ts b/apps/api/src/app/controllers/erc1155/token/get.controller.ts new file mode 100644 index 000000000..b8d781b09 --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/token/get.controller.ts @@ -0,0 +1,34 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import ERC1155Service from '@thxnetwork/api/services/ERC1155Service'; +import SafeService from '@thxnetwork/api/services/SafeService'; + +const validation = [param('id').isMongoId(), param('walletId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const token = await ERC1155Service.queryMintTransaction(await ERC1155Service.findTokenById(req.params.id)); + if (!token) throw new NotFoundError('ERC1155Token not found'); + + const erc1155 = await ERC1155Service.findById(token.erc1155Id); + if (!erc1155) throw new NotFoundError('ERC1155 not found'); + + const metadata = await ERC1155Service.findMetadataById(token.metadataId); + if (!metadata) throw new NotFoundError('ERC1155Metadata not found'); + + const wallet = await SafeService.findById(req.query.walletId as string); + if (!wallet) throw new NotFoundError('Wallet not found for account'); + + const balance = await erc1155.contract.methods.balanceOf(wallet.address, metadata.tokenId).call(); + const tokenUri = token.tokenId ? await erc1155.contract.methods.uri(token.tokenId).call() : ''; + + res.json({ + ...token.toJSON(), + nft: erc1155.toJSON(), + metadata: metadata.toJSON(), + tokenUri, + balance, + }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc1155/token/list.controller.ts b/apps/api/src/app/controllers/erc1155/token/list.controller.ts new file mode 100644 index 000000000..9541e7cb4 --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/token/list.controller.ts @@ -0,0 +1,30 @@ +import { Request, Response } from 'express'; +import { ERC1155TokenDocument } from '@thxnetwork/api/models/ERC1155Token'; +import { query } from 'express-validator'; +import { BadRequestError } from '@thxnetwork/api/util/errors'; +import SafeService from '@thxnetwork/api/services/SafeService'; +import ERC1155Service from '@thxnetwork/api/services/ERC1155Service'; + +const validation = [query('walletId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const wallet = await SafeService.findById(req.query.walletId as string); + if (!wallet) throw new BadRequestError('Wallet not found'); + + const tokens = await ERC1155Service.findTokensByWallet(wallet); + const result = await Promise.all( + tokens.map(async (token: ERC1155TokenDocument) => { + const erc1155 = await ERC1155Service.findById(token.erc1155Id); + if (!erc1155) return; + + const metadata = await ERC1155Service.findMetadataById(token.metadataId); + if (!metadata) return; + + return Object.assign(token.toJSON() as TERC1155Token, { metadata, nft: erc1155 }); + }), + ); + + res.json(result.reverse().filter((token) => !!token)); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc1155/transfer/post.controller.ts b/apps/api/src/app/controllers/erc1155/transfer/post.controller.ts new file mode 100644 index 000000000..3ce087089 --- /dev/null +++ b/apps/api/src/app/controllers/erc1155/transfer/post.controller.ts @@ -0,0 +1,42 @@ +import { Request, Response } from 'express'; +import { body } from 'express-validator'; +import { ForbiddenError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { ERC1155Token } from '@thxnetwork/api/models/ERC1155Token'; +import { Transaction } from '@thxnetwork/api/models/Transaction'; +import { ERC1155 } from '@thxnetwork/api/models/ERC1155'; +import ERC1155Service from '@thxnetwork/api/services/ERC1155Service'; +import SafeService from '@thxnetwork/api/services/SafeService'; + +const validation = [ + body('walletId').isMongoId(), + body('erc1155Id').isMongoId(), + body('erc1155TokenId').isMongoId(), + body('erc1155Amount').isInt(), + body('to').isString(), +]; + +const controller = async (req: Request, res: Response) => { + const erc1155 = await ERC1155.findById(req.body.erc1155Id); + if (!erc1155) throw new NotFoundError('Could not find the ERC1155'); + + const erc1155Token = await ERC1155Token.findById(req.body.erc1155TokenId); + if (!erc1155Token) throw new NotFoundError('Could not find token for wallet'); + + const wallet = await SafeService.findById(req.body.walletId); + if (!wallet) throw new NotFoundError('Could not find wallet for account'); + + const balance = await erc1155.contract.methods.balanceOf(wallet.address, erc1155Token.tokenId).call(); + if (Number(balance) < Number(req.body.erc1155Amount)) throw new ForbiddenError('Insufficient balance'); + + const receiverToken = await ERC1155Service.transferFrom( + erc1155, + wallet, + req.body.to, + erc1155Token, + req.body.erc1155Amount, + ); + const tx = await Transaction.findById(receiverToken.transactions[0]); + + res.status(201).json(tx); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc20/allowance/allowance.router.ts b/apps/api/src/app/controllers/erc20/allowance/allowance.router.ts new file mode 100644 index 000000000..0c07c5e26 --- /dev/null +++ b/apps/api/src/app/controllers/erc20/allowance/allowance.router.ts @@ -0,0 +1,16 @@ +import express from 'express'; +import { assertRequestInput, guard } from '@thxnetwork/api/middlewares'; +import * as ListController from './get.controller'; +import * as CreateController from './post.controller'; + +const router: express.Router = express.Router(); + +router.post( + '/', + guard.check(['erc20:read']), + assertRequestInput(CreateController.validation), + CreateController.controller, +); +router.get('/', guard.check(['erc20:read']), assertRequestInput(ListController.validation), ListController.controller); + +export default router; diff --git a/apps/api/src/app/controllers/erc20/allowance/get.controller.ts b/apps/api/src/app/controllers/erc20/allowance/get.controller.ts new file mode 100644 index 000000000..78a8b60e0 --- /dev/null +++ b/apps/api/src/app/controllers/erc20/allowance/get.controller.ts @@ -0,0 +1,27 @@ +import { Request, Response } from 'express'; +import { query } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import ContractService from '@thxnetwork/api/services/ContractService'; +import WalletService from '@thxnetwork/api/services/WalletService'; + +const validation = [ + query('tokenAddress').isEthereumAddress(), + query('spender').isEthereumAddress(), + query('walletId').isMongoId(), +]; + +const controller = async (req: Request, res: Response) => { + const walletId = req.query.walletId as string; + const wallet = await WalletService.findById(walletId); + if (!wallet) throw new NotFoundError('Could not find wallet for account'); + + const contract = ContractService.getContractFromName( + wallet.chainId, + 'LimitedSupplyToken', + req.query.tokenAddress as string, + ); + const allowanceInWei = await contract.methods.allowance(wallet.address, req.query.spender).call(); + + res.json({ allowanceInWei: String(allowanceInWei) }); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc20/allowance/post.controller.ts b/apps/api/src/app/controllers/erc20/allowance/post.controller.ts new file mode 100644 index 000000000..dc91ff7ef --- /dev/null +++ b/apps/api/src/app/controllers/erc20/allowance/post.controller.ts @@ -0,0 +1,40 @@ +import { Request, Response } from 'express'; +import { body, query } from 'express-validator'; +import { ForbiddenError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { BigNumber } from 'ethers'; +import { getAbiForContractName } from '@thxnetwork/api/services/ContractService'; +import TransactionService from '@thxnetwork/api/services/TransactionService'; +import WalletService from '@thxnetwork/api/services/WalletService'; + +const validation = [ + body('tokenAddress').isEthereumAddress(), + body('spender').isEthereumAddress(), + body('amountInWei').isString(), + query('walletId').isMongoId(), +]; + +const controller = async (req: Request, res: Response) => { + const walletId = req.query.walletId as string; + const wallet = await WalletService.findById(walletId); + if (!wallet) throw new NotFoundError('Wallet not found'); + if (wallet.sub !== req.auth.sub) throw new ForbiddenError('Wallet not owned by sub.'); + + const { web3 } = getProvider(wallet.chainId); + + const abi = getAbiForContractName('LimitedSupplyToken'); + const contract = new web3.eth.Contract(abi, req.body.tokenAddress); + const amount = await contract.methods.balanceOf(wallet.address).call(); + + // Check sufficient BPT Balance + if (BigNumber.from(amount).lt(BigNumber.from(req.body.amountInWei))) + throw new ForbiddenError('Insufficient balance'); + + const fn = contract.methods.approve(req.body.spender, req.body.amountInWei); + + // Propose tx data to relayer and return safeTxHash to client to sign + const tx = await TransactionService.sendSafeAsync(wallet, contract.options.address, fn); + + res.status(201).json([tx]); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc20/balance/balance.router.ts b/apps/api/src/app/controllers/erc20/balance/balance.router.ts new file mode 100644 index 000000000..3b176152d --- /dev/null +++ b/apps/api/src/app/controllers/erc20/balance/balance.router.ts @@ -0,0 +1,9 @@ +import express from 'express'; +import { assertRequestInput, guard } from '@thxnetwork/api/middlewares'; +import * as ReadController from './get.controller'; + +const router: express.Router = express.Router(); + +router.get('/', guard.check(['erc20:read']), assertRequestInput(ReadController.validation), ReadController.controller); + +export default router; diff --git a/apps/api/src/app/controllers/erc20/balance/get.controller.ts b/apps/api/src/app/controllers/erc20/balance/get.controller.ts new file mode 100644 index 000000000..ebc9ee756 --- /dev/null +++ b/apps/api/src/app/controllers/erc20/balance/get.controller.ts @@ -0,0 +1,23 @@ +import { Request, Response } from 'express'; +import { query } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import ContractService from '@thxnetwork/api/services/ContractService'; +import WalletService from '@thxnetwork/api/services/WalletService'; + +const validation = [query('walletId').isMongoId(), query('tokenAddress').isEthereumAddress()]; + +const controller = async (req: Request, res: Response) => { + const walletId = req.query.walletId as string; + const wallet = await WalletService.findById(walletId); + if (!wallet) throw new NotFoundError('Wallet not found'); + + const contract = ContractService.getContractFromAbi( + wallet.chainId, + ContractService.getAbiForContractName('LimitedSupplyToken'), + req.query.tokenAddress as string, + ); + const balanceInWei = await contract.methods.balanceOf(wallet.address).call(); + + res.json({ balanceInWei }); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc20/delete.controller.ts b/apps/api/src/app/controllers/erc20/delete.controller.ts new file mode 100644 index 000000000..79e2529df --- /dev/null +++ b/apps/api/src/app/controllers/erc20/delete.controller.ts @@ -0,0 +1,12 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { ERC20 } from '@thxnetwork/api/models'; + +const validation = [param('id').exists().isMongoId()]; + +const controller = async (req: Request, res: Response) => { + await ERC20.deleteOne({ _id: req.params.id }); + return res.status(204).end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc20/erc20.router.ts b/apps/api/src/app/controllers/erc20/erc20.router.ts new file mode 100644 index 000000000..4b5839b72 --- /dev/null +++ b/apps/api/src/app/controllers/erc20/erc20.router.ts @@ -0,0 +1,72 @@ +import express from 'express'; +import { assertRequestInput, guard } from '@thxnetwork/api/middlewares'; +import { upload } from '@thxnetwork/api/util/multer'; + +import RouterAllowance from './allowance/allowance.router'; +import RouterTransfer from './transfer/transfer.router'; +import RouterBalance from './balance/balance.router'; +import RouterPreview from './preview/preview.router'; + +import * as CreateController from './post.controller'; +import * as ReadController from './get.controller'; +import * as UpdateController from './patch.controller'; +import * as DeleteController from './delete.controller'; +import * as ListController from './list.controller'; + +import * as ListERC20Token from './token/list.controller'; +import * as ReadERC20Token from './token/get.controller'; +import * as ImportERC20 from './token/post.controller'; + +const router: express.Router = express.Router(); + +router.use('/transfer', RouterTransfer); +router.use('/balance', RouterBalance); +router.use('/allowance', RouterAllowance); +router.use('/preview', RouterPreview); + +// Token Resource should move into /wallet +router.get( + '/token', + guard.check(['erc20:read']), + assertRequestInput(ListERC20Token.validation), + ListERC20Token.controller, +); +router.get('/token/:id', guard.check(['erc20:read']), ReadERC20Token.controller); + +// Should be /import controller +router.post( + '/token', + guard.check(['erc20:write', 'erc20:read']), + assertRequestInput(ImportERC20.validation), + ImportERC20.controller, +); +// End + +router.post( + '/', + upload.single('file'), + guard.check(['erc20:write', 'erc20:read']), + assertRequestInput(CreateController.validation), + CreateController.controller, +); +router.get( + '/:id', + guard.check(['erc20:read']), + assertRequestInput(ReadController.validation), + ReadController.controller, +); +router.patch( + '/:id', + guard.check(['erc20:write', 'erc20:read']), + assertRequestInput(UpdateController.validation), + UpdateController.controller, +); +router.delete( + '/:id', + guard.check(['erc20:write']), + assertRequestInput(DeleteController.validation), + DeleteController.controller, +); +router.get('/', guard.check(['erc20:read']), assertRequestInput(ListController.validation), ListController.controller); + +export default router; diff --git a/apps/api/src/app/controllers/erc20/erc20.test.ts b/apps/api/src/app/controllers/erc20/erc20.test.ts new file mode 100644 index 000000000..b9634d919 --- /dev/null +++ b/apps/api/src/app/controllers/erc20/erc20.test.ts @@ -0,0 +1,129 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { ChainId, ERC20Type } from '@thxnetwork/common/enums'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { isAddress } from 'ethers/lib/utils'; +import { dashboardAccessToken } from '@thxnetwork/api/util/jest/constants'; +import { createImage } from '@thxnetwork/api/util/jest/images'; +import { toWei } from 'web3-utils'; + +const http = request.agent(app); + +describe('ERC20', () => { + const totalSupply = toWei('1000'), + name = 'Test Token', + symbol = 'TTK'; + let tokenAddress: string, tokenName: string, tokenSymbol: string, erc20Id: string; + + beforeAll(beforeAllCallback); + afterAll(afterAllCallback); + + describe('POST /erc20', () => { + it('Able to create unlimited token and return address', (done) => { + http.post('/v1/erc20') + .set('Authorization', dashboardAccessToken) + .send({ + name: 'Test Token', + symbol: 'TTK', + chainId: ChainId.Hardhat, + totalSupply: 0, + type: ERC20Type.Unlimited, + }) + .expect(({ body }: request.Response) => { + expect(body._id).toBeDefined(); + expect(isAddress(body.address)).toBe(true); + }) + .expect(201, done); + }); + + it('Able to create limited token and return address', async () => { + const image = createImage(); + await http + .post('/v1/erc20') + .set('Authorization', dashboardAccessToken) + .attach('file', image, { + filename: 'test.jpg', + contentType: 'image/jpg', + }) + .field({ + name, + symbol, + chainId: ChainId.Hardhat, + totalSupply, + type: ERC20Type.Limited, + }) + .expect(({ body }: request.Response) => { + expect(isAddress(body._id)).toBeDefined(); + expect(isAddress(body.address)).toBe(true); + expect(body.logoImgUrl).toBeDefined(); + erc20Id = body._id; + tokenAddress = body.address; + tokenName = body.name; + tokenSymbol = body.symbol; + }) + .expect(201); + }); + + it('Able to return list of created token', (done) => { + http.get('/v1/erc20') + .set('Authorization', dashboardAccessToken) + .expect(({ body }: request.Response) => { + expect(body.length).toEqual(2); + }) + .expect(200, done); + }); + + it('Able to return a created token', (done) => { + http.get('/v1/erc20/' + erc20Id) + .set('Authorization', dashboardAccessToken) + .expect(({ body }: request.Response) => { + expect(body).toBeDefined(); + expect(isAddress(body.address)).toBe(true); + expect(body.type).toBe(ERC20Type.Limited); + expect(body.totalSupplyInWei).toBe(totalSupply); + expect(body.name).toBe(name); + expect(body.symbol).toBe(symbol); + expect(body.decimals).toBe(18); + }) + .expect(200, done); + }); + }); + + describe('PATCH /erc20', () => { + it('should to update a created token', (done) => { + http.patch('/v1/erc20/' + erc20Id) + .set('Authorization', dashboardAccessToken) + .send() + .expect(({ body }: request.Response) => { + expect(body).toBeDefined(); + }) + .expect(200, done); + }); + }); + describe('DELETE /erc20/:id', () => { + it('Able to delete created token', (done) => { + http.delete('/v1/erc20/' + erc20Id) + .set('Authorization', dashboardAccessToken) + .expect(204, done); + }); + }); + + describe('POST /erc20/preview', () => { + it('should return name symbol and total supply of an oncChain ERC20Token', (done) => { + http.get('/v1/erc20/preview') + .set('Authorization', dashboardAccessToken) + .query({ + chainId: ChainId.Hardhat, + address: tokenAddress, + }) + .send() + .expect(({ body }: request.Response) => { + expect(body).toBeDefined(); + expect(body.name).toBe(tokenName); + expect(body.symbol).toBe(tokenSymbol); + expect(body.totalSupplyInWei).toBe(totalSupply); + }) + .expect(200, done); + }); + }); +}); diff --git a/apps/api/src/app/controllers/erc20/get.controller.ts b/apps/api/src/app/controllers/erc20/get.controller.ts new file mode 100644 index 000000000..0f49f8840 --- /dev/null +++ b/apps/api/src/app/controllers/erc20/get.controller.ts @@ -0,0 +1,37 @@ +import { Request, Response } from 'express'; +import ERC20Service from '@thxnetwork/api/services/ERC20Service'; +import { param } from 'express-validator'; +import { fromWei } from 'web3-utils'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + let erc20 = await ERC20Service.queryDeployTransaction(await ERC20Service.getById(req.params.id)); + if (!erc20) throw new NotFoundError('ERC20 not found'); + + // Check if pending transaction is mined. + if (!erc20.address) erc20 = await ERC20Service.queryDeployTransaction(erc20); + + // Still no address. + if (!erc20.address) return res.send(erc20); + + const { defaultAccount } = getProvider(erc20.chainId); + const [totalSupplyInWei, decimalsString, adminBalanceInWei] = await Promise.all([ + erc20.contract.methods.totalSupply().call(), + erc20.contract.methods.decimals().call(), + erc20.contract.methods.balanceOf(defaultAccount).call(), + ]); + const decimals = Number(decimalsString); + const adminBalance = Number(fromWei(adminBalanceInWei, 'ether')); + + res.status(200).json({ + ...erc20.toJSON(), + totalSupplyInWei, + decimals, + adminBalance, + }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc20/list.controller.ts b/apps/api/src/app/controllers/erc20/list.controller.ts new file mode 100644 index 000000000..40d5ecc8c --- /dev/null +++ b/apps/api/src/app/controllers/erc20/list.controller.ts @@ -0,0 +1,11 @@ +import ERC20Service from '@thxnetwork/api/services/ERC20Service'; +import { Request, Response } from 'express'; + +const validation = []; + +const controller = async (req: Request, res: Response) => { + const erc20s = await ERC20Service.findBySub(req.auth.sub); + return res.json(erc20s); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc20/patch.controller.ts b/apps/api/src/app/controllers/erc20/patch.controller.ts new file mode 100644 index 000000000..c1b8b88ae --- /dev/null +++ b/apps/api/src/app/controllers/erc20/patch.controller.ts @@ -0,0 +1,15 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import ERC20Service from '@thxnetwork/api/services/ERC20Service'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const erc20 = await ERC20Service.getById(req.params.id); + if (!erc20) throw new NotFoundError('Could not find the token for this id'); + + const result = await ERC20Service.update(erc20, req.body); + return res.json(result); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc20/post.controller.ts b/apps/api/src/app/controllers/erc20/post.controller.ts new file mode 100644 index 000000000..902fe4be3 --- /dev/null +++ b/apps/api/src/app/controllers/erc20/post.controller.ts @@ -0,0 +1,39 @@ +import { Request, Response } from 'express'; +import { body, check, query } from 'express-validator'; +import ERC20Service from '@thxnetwork/api/services/ERC20Service'; +import ImageService from '@thxnetwork/api/services/ImageService'; + +const validation = [ + body('name').exists().isString(), + body('symbol').exists().isString(), + body('chainId').exists().isNumeric(), + body('type').exists().isNumeric(), + body('totalSupply').optional().isNumeric(), + check('file') + .optional() + .custom((value, { req }) => { + return ['jpg', 'jpeg', 'gif', 'png'].includes(req.file.mimetype); + }), + query('forceSync').optional().isBoolean(), +]; + +const controller = async (req: Request, res: Response) => { + const logoImgUrl = req.file && (await ImageService.upload(req.file)); + const forceSync = req.query.forceSync !== undefined ? req.query.forceSync === 'true' : false; + + const erc20 = await ERC20Service.deploy( + { + name: req.body.name, + symbol: req.body.symbol, + chainId: req.body.chainId, + totalSupply: req.body.totalSupply, + type: req.body.type, + sub: req.auth.sub, + logoImgUrl, + }, + forceSync, + ); + + res.status(201).json(erc20); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc20/preview/get.controller.ts b/apps/api/src/app/controllers/erc20/preview/get.controller.ts new file mode 100644 index 000000000..e2d152847 --- /dev/null +++ b/apps/api/src/app/controllers/erc20/preview/get.controller.ts @@ -0,0 +1,20 @@ +import { Request, Response } from 'express'; +import { query } from 'express-validator'; +import { ChainId } from '@thxnetwork/common/enums'; +import ContractService from '@thxnetwork/api/services/ContractService'; + +const validation = [query('chainId').isInt(), query('address').isEthereumAddress()]; + +const controller = async (req: Request, res: Response) => { + const chainId = req.query.chainId as unknown as ChainId; + const contractAddress = req.query.address as string; + const contract = ContractService.getContractFromName(chainId, 'LimitedSupplyToken', contractAddress); + const [name, symbol, totalSupplyInWei] = await Promise.all([ + contract.methods.name().call(), + contract.methods.symbol().call(), + contract.methods.totalSupply().call(), + ]); + + res.json({ name, symbol, totalSupplyInWei }); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc20/preview/preview.router.ts b/apps/api/src/app/controllers/erc20/preview/preview.router.ts new file mode 100644 index 000000000..3b176152d --- /dev/null +++ b/apps/api/src/app/controllers/erc20/preview/preview.router.ts @@ -0,0 +1,9 @@ +import express from 'express'; +import { assertRequestInput, guard } from '@thxnetwork/api/middlewares'; +import * as ReadController from './get.controller'; + +const router: express.Router = express.Router(); + +router.get('/', guard.check(['erc20:read']), assertRequestInput(ReadController.validation), ReadController.controller); + +export default router; diff --git a/apps/api/src/app/controllers/erc20/token/get.controller.ts b/apps/api/src/app/controllers/erc20/token/get.controller.ts new file mode 100644 index 000000000..a34aa3da7 --- /dev/null +++ b/apps/api/src/app/controllers/erc20/token/get.controller.ts @@ -0,0 +1,31 @@ +import { Request, Response } from 'express'; +import { param, query } from 'express-validator'; +import { fromWei } from 'web3-utils'; +import { BadRequestError, NotFoundError } from '@thxnetwork/api/util/errors'; +import ERC20Service from '@thxnetwork/api/services/ERC20Service'; +import WalletService from '@thxnetwork/api/services/WalletService'; + +const validation = [param('id').isMongoId(), query('walletId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const token = await ERC20Service.getTokenById(req.params.id); + if (!token) throw new NotFoundError('ERC20Token not found'); + + const erc20 = await ERC20Service.getById(token.erc20Id); + if (!erc20) throw new NotFoundError('ERC20 not found'); + + const wallet = await WalletService.findById(req.query.walletId as string); + if (!wallet) throw new BadRequestError('Wallet not found'); + + const walletBalanceInWei = await erc20.contract.methods.balanceOf(wallet.address).call(); + const walletBalance = Number(fromWei(walletBalanceInWei, 'ether')); + + res.json({ + ...token.toJSON(), + walletBalanceInWei, + walletBalance, + erc20, + }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc20/token/list.controller.ts b/apps/api/src/app/controllers/erc20/token/list.controller.ts new file mode 100644 index 000000000..b9997026d --- /dev/null +++ b/apps/api/src/app/controllers/erc20/token/list.controller.ts @@ -0,0 +1,22 @@ +import { Request, Response } from 'express'; +import { query } from 'express-validator'; +import { BadRequestError } from '@thxnetwork/api/util/errors'; +import ERC20Service from '@thxnetwork/api/services/ERC20Service'; +import SafeService from '@thxnetwork/api/services/SafeService'; + +const validation = [query('walletId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const wallet = await SafeService.findById(req.query.walletId as string); + if (!wallet) throw new BadRequestError('Wallet not found'); + + const tokens = await ERC20Service.getTokensForWallet(wallet); + + res.json( + tokens.reverse().filter((token: TERC20Token & { erc20: TERC20 }) => { + return token && wallet.chainId === token.erc20.chainId; + }), + ); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc20/token/post.controller.ts b/apps/api/src/app/controllers/erc20/token/post.controller.ts new file mode 100644 index 000000000..9142aa86d --- /dev/null +++ b/apps/api/src/app/controllers/erc20/token/post.controller.ts @@ -0,0 +1,21 @@ +import { Request, Response } from 'express'; +import { body } from 'express-validator'; +import ERC20Service from '@thxnetwork/api/services/ERC20Service'; + +const validation = [ + body('address').exists().isString(), + body('chainId').exists().isInt(), + body('logoImgUrl').optional().isString(), +]; + +const controller = async (req: Request, res: Response) => { + const erc20 = await ERC20Service.importToken( + Number(req.body.chainId), + req.body.address, + req.auth.sub, + req.body.logoImgUrl, + ); + + res.status(201).json(erc20); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc20/token/token.router.ts b/apps/api/src/app/controllers/erc20/token/token.router.ts new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/src/app/controllers/erc20/transfer/erc20-transfer.test.ts b/apps/api/src/app/controllers/erc20/transfer/erc20-transfer.test.ts new file mode 100644 index 000000000..48fd89d29 --- /dev/null +++ b/apps/api/src/app/controllers/erc20/transfer/erc20-transfer.test.ts @@ -0,0 +1,104 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { WalletDocument, ERC20, ERC20Document } from '@thxnetwork/api/models'; +import { ChainId, ERC20Type, WalletVariant } from '@thxnetwork/common/enums'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { + dashboardAccessToken, + userWalletAddress2, + userWalletPrivateKey, + widgetAccessToken, +} from '@thxnetwork/api/util/jest/constants'; +import { toWei } from 'web3-utils'; +import { poll } from '@thxnetwork/api/util/polling'; +import { signTxHash } from '@thxnetwork/api/util/jest/network'; + +const user = request.agent(app); + +describe('ERC20 Transfer', () => { + let erc20: ERC20Document, wallet: WalletDocument; + + beforeAll(beforeAllCallback); + afterAll(afterAllCallback); + + describe('POST /erc20', () => { + it('HTTP 201', (done) => { + user.post('/v1/erc20') + .set('Authorization', dashboardAccessToken) + .send({ + name: 'Test Token', + symbol: 'TTK', + totalSupply: toWei('100', 'ether'), + type: ERC20Type.Limited, + chainId: ChainId.Hardhat, + }) + .expect(({ body }: request.Response) => { + expect(body._id).toBeDefined(); + erc20 = body; + }) + .expect(201, done); + }); + }); + + describe('GET /wallet and transfer erc20', () => { + it('HTTP 200', (done) => { + user.get('/v1/account/wallets') + .set({ Authorization: widgetAccessToken }) + .send() + .expect(async ({ body }: request.Response) => { + wallet = body.find((w: WalletDocument) => w.variant === WalletVariant.Safe); + expect(wallet).toBeDefined(); + expect(wallet.address).toBeDefined(); + }) + .expect(200, done); + }); + + it('Transfer ERC20', async () => { + const { contract } = await ERC20.findById(erc20._id); + await contract.methods.transfer(wallet.address, toWei('100', 'ether')).send(); + + const balanceInWei = await contract.methods.balanceOf(wallet.address).call(); + expect(balanceInWei).toBe(toWei('100', 'ether')); + }); + }); + + describe('POST /erc20/transfer', () => { + it('HTTP 201', async () => { + const res = await user + .post('/v1/erc20/transfer') + .set({ Authorization: widgetAccessToken }) + .send({ + walletId: String(wallet._id), + erc20Id: erc20._id, + to: userWalletAddress2, + amount: toWei('1', 'ether'), + chainId: ChainId.Hardhat, + }); + expect(res.body.safeTxHash).toBeDefined(); + expect(res.status).toBe(201); + + const { safeTxHash, signature } = await signTxHash( + wallet.address, + res.body.safeTxHash, + userWalletPrivateKey, + ); + const res2 = await user + .post(`/v1/account/wallets/confirm`) + .set({ Authorization: widgetAccessToken }) + .query({ walletId: String(wallet._id) }) + .send({ chainId: ChainId.Hardhat, safeTxHash, signature }); + + expect(res2.status).toBe(200); + }); + it('Wait for balance', async () => { + const { contract } = await ERC20.findById(erc20._id); + await poll( + contract.methods.balanceOf(userWalletAddress2).call, + (result: string) => result !== toWei('1', 'ether'), + 1000, + ); + const balanceInWei = await contract.methods.balanceOf(userWalletAddress2).call(); + expect(balanceInWei).toEqual(toWei('1', 'ether')); + }); + }); +}); diff --git a/apps/api/src/app/controllers/erc20/transfer/post.controller.ts b/apps/api/src/app/controllers/erc20/transfer/post.controller.ts new file mode 100644 index 000000000..c741ccb32 --- /dev/null +++ b/apps/api/src/app/controllers/erc20/transfer/post.controller.ts @@ -0,0 +1,32 @@ +import { Request, Response } from 'express'; +import { body } from 'express-validator'; +import { InsufficientBalanceError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { BN } from 'bn.js'; +import { ERC20 } from '@thxnetwork/api/models'; +import SafeService from '@thxnetwork/api/services/SafeService'; +import ERC20Service from '@thxnetwork/api/services/ERC20Service'; + +const validation = [ + body('walletId').isMongoId(), + body('erc20Id').isMongoId(), + body('to').isString(), + body('amount').isString(), +]; + +const controller = async (req: Request, res: Response) => { + const erc20 = await ERC20.findById(req.body.erc20Id); + if (!erc20) throw new NotFoundError('Could not find the ERC20'); + + const wallet = await SafeService.findById(req.body.walletId); + if (!wallet) throw new NotFoundError('Could not find wallet for account'); + + const walletBalanceInWei = await erc20.contract.methods.balanceOf(wallet.address).call(); + const balanceInWei = new BN(walletBalanceInWei); + const amountInWei = new BN(req.body.amount); + if (amountInWei.gt(balanceInWei)) throw new InsufficientBalanceError(); + + const tx = await ERC20Service.transferFrom(erc20, wallet, req.body.to, String(amountInWei)); + + res.status(201).json(tx); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc20/transfer/transfer.router.ts b/apps/api/src/app/controllers/erc20/transfer/transfer.router.ts new file mode 100644 index 000000000..36036cb0d --- /dev/null +++ b/apps/api/src/app/controllers/erc20/transfer/transfer.router.ts @@ -0,0 +1,9 @@ +import express from 'express'; +import { assertRequestInput } from '@thxnetwork/api/middlewares'; +import * as CreateController from './post.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.post('/', assertRequestInput(CreateController.validation), CreateController.controller); + +export default router; diff --git a/apps/api/src/app/controllers/erc721/delete.controller.ts b/apps/api/src/app/controllers/erc721/delete.controller.ts new file mode 100644 index 000000000..693743e22 --- /dev/null +++ b/apps/api/src/app/controllers/erc721/delete.controller.ts @@ -0,0 +1,16 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { ForbiddenError } from '@thxnetwork/api/util/errors'; +import { ERC721 } from '@thxnetwork/api/models/ERC721'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const erc721 = await ERC721.findById(req.params.id); + if (erc721.sub !== req.auth.sub) throw new ForbiddenError('Not your ERC721'); + + await erc721.deleteOne(); + + return res.status(204).end(); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc721/erc721.router.ts b/apps/api/src/app/controllers/erc721/erc721.router.ts new file mode 100644 index 000000000..a1a79c610 --- /dev/null +++ b/apps/api/src/app/controllers/erc721/erc721.router.ts @@ -0,0 +1,107 @@ +import express from 'express'; +import { assertPoolAccess, assertRequestInput, guard } from '@thxnetwork/api/middlewares'; +import { upload } from '@thxnetwork/api/util/multer'; + +import * as ReadERC721 from './get.controller'; +import * as ListERC721 from './list.controller'; +import * as ListERC721Metadata from './metadata/list.controller'; +import * as ListERC721Token from './token/list.controller'; +import * as ReadERC721Token from './token/get.controller'; +import * as RemoveERC721 from './delete.controller'; +import * as CreateERC721 from './post.controller'; +import * as CreateMultipleERC721Metadata from './metadata/images/post.controller'; +import * as UpdateERC721 from './patch.controller'; +import * as ReadERC721Metadata from './metadata/get.controller'; +import * as CreateERC721Metadata from './metadata/post.controller'; +import * as PatchERC721Metadata from './metadata/patch.controller'; +import * as DeleteERC721Metadata from './metadata/delete.controller'; +import * as ImportERC721Contract from './import/post.controller'; +import * as PreviewERC721Contract from './import/preview/post.controller'; +import * as CreateERC721Transfer from './transfer/post.controller'; + +const router: express.Router = express.Router(); + +router.get( + '/token', + guard.check(['erc721:read']), + assertRequestInput(ListERC721Token.validation), + ListERC721Token.controller, +); +router.get('/token/:id', guard.check(['erc721:read']), ReadERC721Token.controller); +router.get('/', guard.check(['erc721:read']), assertRequestInput(ListERC721.validation), ListERC721.controller); +router.get('/:id', guard.check(['erc721:read']), assertRequestInput(ReadERC721.validation), ReadERC721.controller); + +router.post( + '/', + upload.single('file'), + guard.check(['erc721:read', 'erc721:write']), + assertRequestInput(CreateERC721.validation), + CreateERC721.controller, +); + +router.post( + '/transfer', + // guard.check(['erc721_transfer:read', 'erc721_transfer:write']), + assertRequestInput(CreateERC721Transfer.validation), + CreateERC721Transfer.controller, +); +router.post( + '/import', + ImportERC721Contract.controller, + assertPoolAccess, + assertRequestInput(ImportERC721Contract.validation), +); +router.post('/preview', assertRequestInput(PreviewERC721Contract.validation), PreviewERC721Contract.controller); +router.patch( + '/:id/metadata/:metadataId', + guard.check(['erc721:write']), + assertRequestInput(PatchERC721Metadata.validation), + PatchERC721Metadata.controller, +); + +router.delete( + '/:id/metadata/:metadataId', + guard.check(['erc721:write']), + assertRequestInput(DeleteERC721Metadata.validation), + DeleteERC721Metadata.controller, +); + +router.get('/:id/metadata', guard.check(['erc721:read']), ListERC721Metadata.controller); + +router.post( + '/:id/metadata/', + guard.check(['erc721:write']), + assertRequestInput(CreateERC721Metadata.validation), + CreateERC721Metadata.controller, +); + +router.post( + '/:id/metadata/zip', + upload.single('file'), + guard.check(['erc721:write']), + assertRequestInput(CreateMultipleERC721Metadata.validation), + CreateMultipleERC721Metadata.controller, +); + +router.patch( + '/:id', + guard.check(['erc721:write', 'erc721:read']), + assertRequestInput(UpdateERC721.validation), + UpdateERC721.controller, +); + +router.get( + '/:id/metadata/:metadataId', + guard.check(['erc721:read']), + ReadERC721Metadata.controller, + assertRequestInput(ReadERC721Metadata.validation), +); + +router.delete( + '/:id', + guard.check(['erc721:read', 'erc721:write']), + assertRequestInput(RemoveERC721.validation), + RemoveERC721.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/erc721/erc721.test.ts b/apps/api/src/app/controllers/erc721/erc721.test.ts new file mode 100644 index 000000000..22f962f5b --- /dev/null +++ b/apps/api/src/app/controllers/erc721/erc721.test.ts @@ -0,0 +1,93 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { ChainId } from '@thxnetwork/common/enums'; +import { isAddress } from 'web3-utils'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { dashboardAccessToken } from '@thxnetwork/api/util/jest/constants'; +import { createImage } from '@thxnetwork/api/util/jest/images'; + +const user = request.agent(app); + +describe('ERC721', () => { + const chainId = ChainId.Hardhat, + name = 'Planets of the Galaxy', + symbol = 'GLXY', + description = 'Collection full of rarities.'; + let erc721ID: string; + + beforeAll(beforeAllCallback); + afterAll(afterAllCallback); + + describe('POST /erc721', () => { + it('should create and return contract details', async () => { + const logoImg = createImage(); + await user + .post('/v1/erc721') + .set('Authorization', dashboardAccessToken) + .attach('file', logoImg, { filename: 'logoImg.jpg', contentType: 'image/jpg' }) + .field({ + chainId, + name, + symbol, + description, + }) + .expect(({ body }: request.Response) => { + expect(body._id).toBeDefined(); + expect(body.chainId).toBe(chainId); + expect(body.name).toBe(name); + expect(body.symbol).toBe(symbol); + expect(body.description).toBe(description); + expect(isAddress(body.address)).toBe(true); + expect(body.logoImgUrl).toBeDefined(); + erc721ID = body._id; + }) + .expect(201); + }); + }); + + describe('GET /erc721/:id', () => { + it('should return contract details', (done) => { + user.get('/v1/erc721/' + erc721ID) + .set('Authorization', dashboardAccessToken) + .send() + .expect(({ body }: request.Response) => { + expect(body.chainId).toBe(chainId); + expect(body.name).toBe(name); + expect(body.symbol).toBe(symbol); + expect(body.description).toBe(description); + expect(isAddress(body.address)).toBe(true); + expect(body.logoImgUrl).toBeDefined(); + }) + .expect(200, done); + }); + it('should 400 for invalid ID', (done) => { + user.get('/v1/erc721/' + 'invalid_id') + .set('Authorization', dashboardAccessToken) + .send() + .expect(({ body }: request.Response) => { + expect(body.errors[0].msg).toContain('Invalid value'); + }) + .expect(400, done); + }); + it('should 404 if not known', (done) => { + user.get('/v1/erc721/' + '62397f69760ac5f9ab4454df') + .set('Authorization', dashboardAccessToken) + .send() + .expect(({ body }: request.Response) => { + expect(body.error.message).toContain('Not Found'); + }) + .expect(404, done); + }); + describe('PATCH /erc721/:id', () => { + it('should update a created token', (done) => { + user.patch('/v1/erc721/' + erc721ID) + .set('Authorization', dashboardAccessToken) + .send() + .expect(({ body }: request.Response) => { + expect(body).toBeDefined(); + }) + .expect(200, done); + }); + }); + }); +}); diff --git a/apps/api/src/app/controllers/erc721/get.controller.ts b/apps/api/src/app/controllers/erc721/get.controller.ts new file mode 100644 index 000000000..f4451041d --- /dev/null +++ b/apps/api/src/app/controllers/erc721/get.controller.ts @@ -0,0 +1,28 @@ +import { param } from 'express-validator'; +import { Request, Response } from 'express'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + let erc721 = await ERC721Service.findById(req.params.id); + if (!erc721) throw new NotFoundError(); + + // Check if pending transaction is mined. + if (!erc721.address) { + erc721 = await ERC721Service.queryDeployTransaction(erc721); + } + + // Still no address. + if (!erc721.address) { + return res.send(erc721); + } + + const totalSupply = await erc721.contract.methods.totalSupply().call(); + const owner = await erc721.contract.methods.owner().call(); + + res.json({ ...erc721.toJSON(), totalSupply, owner }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc721/import/erc721-import.test.ts b/apps/api/src/app/controllers/erc721/import/erc721-import.test.ts new file mode 100644 index 000000000..e146ef49d --- /dev/null +++ b/apps/api/src/app/controllers/erc721/import/erc721-import.test.ts @@ -0,0 +1,102 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { Contract } from 'web3-eth-contract'; +import { ChainId } from '@thxnetwork/common/enums'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { dashboardAccessToken, sub } from '@thxnetwork/api/util/jest/constants'; +import { alchemy } from '@thxnetwork/api/util/alchemy'; +import { deployERC721, mockGetNftsForOwner } from '@thxnetwork/api/util/jest/erc721'; +import { ERC721Document, PoolDocument } from '@thxnetwork/api/models'; +import { getProvider } from '@thxnetwork/api/util/network'; +import TransactionService from '@thxnetwork/api/services/TransactionService'; + +const user = request.agent(app); + +describe('ERC721 import', () => { + let erc721: ERC721Document, pool: PoolDocument, nftContract: Contract; + const chainId = ChainId.Hardhat, + nftName = 'Test Collection', + nftSymbol = 'TST'; + + beforeAll(beforeAllCallback); + afterAll(afterAllCallback); + + describe('POST /pools', () => { + it('HTTP 201', (done) => { + user.post('/v1/pools') + .set('Authorization', dashboardAccessToken) + .send({ chainId }) + .expect((res: request.Response) => { + pool = res.body; + }) + .expect(201, done); + }); + }); + + describe('POST /erc721/import', () => { + it('HTTP 201`', async () => { + // Create 1 NFT collection + nftContract = await deployERC721(nftName, nftSymbol); + + // Mint 1 token in the collection + await TransactionService.sendAsync( + nftContract.options.address, + nftContract.methods.mint(pool.safeAddress, 'tokenuri.json'), + chainId, + ); + + // Mock Alchemy SDK return value for getNftsForOwner + jest.spyOn(alchemy.nft, 'getNftsForOwner').mockImplementation(() => + Promise.resolve(mockGetNftsForOwner(nftContract.options.address, nftName, nftSymbol) as any), + ); + + // Run the import for the deployed contract address + await user + .post('/v1/erc721/import') + .set({ 'Authorization': dashboardAccessToken, 'X-PoolId': pool._id }) + .send({ chainId, contractAddress: nftContract.options.address }) + .expect(({ body }: request.Response) => { + expect(body.erc721._id).toBeDefined(); + expect(body.erc721.address).toBe(nftContract.options.address); + erc721 = body.erc721; + }) + .expect(201); + }); + }); + + describe('GET /erc721/:id', () => { + const { defaultAccount } = getProvider(chainId); + + it('HTTP 200', (done) => { + user.get(`/v1/erc721/${erc721._id}`) + .set('Authorization', dashboardAccessToken) + .send() + .expect(({ body }: request.Response) => { + expect(body.chainId).toBe(chainId); + expect(body.sub).toBe(sub); + expect(body.name).toBe(nftName); + expect(body.symbol).toBe(nftSymbol); + expect(body.address).toBe(nftContract.options.address); + expect(body.totalSupply).toBe('1'); + expect(body.owner).toBe(defaultAccount); + }) + .expect(200, done); + }); + }); + + describe('GET /erc721/:id/metadata', () => { + it('HTTP 200', (done) => { + user.get(`/v1/erc721/${erc721._id}/metadata`) + .set('Authorization', dashboardAccessToken) + .send() + .expect(({ body }: request.Response) => { + expect(body.total).toBe(1); + expect(body.results[0].name).toBeDefined(); + expect(body.results[0].description).toBeDefined(); + expect(body.results[0].image).toBeDefined(); + expect(body.results[0].externalUrl).toBeDefined(); + }) + .expect(200, done); + }); + }); +}); diff --git a/apps/api/src/app/controllers/erc721/import/post.controller.ts b/apps/api/src/app/controllers/erc721/import/post.controller.ts new file mode 100644 index 000000000..d9b304d03 --- /dev/null +++ b/apps/api/src/app/controllers/erc721/import/post.controller.ts @@ -0,0 +1,86 @@ +import { body } from 'express-validator'; +import { Request, Response } from 'express'; +import { ERC721, ERC721Token, ERC721Metadata, Wallet } from '@thxnetwork/api/models'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { getNFTsForOwner } from '@thxnetwork/api/util/alchemy'; +import { ChainId, ERC721TokenState, NFTVariant } from '@thxnetwork/common/enums'; +import { toChecksumAddress } from 'web3-utils'; + +const validation = [ + body('address').isEthereumAddress(), + body('contractAddress').exists(), + body('chainId').exists().isNumeric(), +]; + +const controller = async (req: Request, res: Response) => { + const chainId = Number(req.body.chainId) as ChainId; + const contractAddress = toChecksumAddress(req.body.contractAddress); + const safeAddress = toChecksumAddress(req.body.address); + const ownedNfts = await getNFTsForOwner(safeAddress, contractAddress); + if (!ownedNfts.length) throw new NotFoundError('Could not find NFT tokens for this contract address'); + + const { address, name, symbol } = ownedNfts[0].contract; + const erc721 = await ERC721.findOneAndUpdate( + { + sub: req.auth.sub, + chainId, + address: toChecksumAddress(address), + }, + { + variant: NFTVariant.ERC721, + sub: req.auth.sub, + chainId, + address: toChecksumAddress(address), + name, + symbol, + archived: false, + }, + { upsert: true, new: true }, + ); + const erc721Tokens = await Promise.all( + ownedNfts.map(async ({ name, description, collection, tokenId, tokenUri, image }) => { + try { + const metadata = await ERC721Metadata.findOneAndUpdate( + { + erc721Id: erc721.id, + externalUrl: collection.externalUrl, + }, + { + erc721Id: erc721.id, + name, + description, + image, + imageUrl: image.originalUrl, + externalUrl: collection.externalUrl, + }, + { upsert: true, new: true }, + ); + const safe = await Wallet.findOne({ + address: req.body.address, + chainId: req.body.chainId, + }); + const token = await ERC721Token.findOneAndUpdate( + { tokenId, walletId: safe.id, erc721Id: erc721.id }, + { + walletId: safe.id, + erc721Id: erc721.id, + recipient: safe.address, + metadataId: metadata.id, + tokenUri, + tokenId, + state: ERC721TokenState.Minted, + }, + { upsert: true, new: true }, + ); + + return { ...token.toJSON(), metadata: metadata.toJSON() }; + } catch (error) { + console.log(error); + } + }), + ); + + res.status(201).json({ erc721, erc721Tokens }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc721/import/preview/post.controller.ts b/apps/api/src/app/controllers/erc721/import/preview/post.controller.ts new file mode 100644 index 000000000..ecc02ab36 --- /dev/null +++ b/apps/api/src/app/controllers/erc721/import/preview/post.controller.ts @@ -0,0 +1,11 @@ +import { getNFTsForOwner } from '@thxnetwork/api/util/alchemy'; +import { Request, Response } from 'express'; +import { body } from 'express-validator'; + +const validation = [body('address').exists().isString(), body('chainId').exists().isInt()]; + +const controller = async (req: Request, res: Response) => { + const ownedNFTs = await getNFTsForOwner(req.body.address, req.body.contractAddress); + res.status(200).json(ownedNFTs); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc721/list.controller.ts b/apps/api/src/app/controllers/erc721/list.controller.ts new file mode 100644 index 000000000..6019f706d --- /dev/null +++ b/apps/api/src/app/controllers/erc721/list.controller.ts @@ -0,0 +1,11 @@ +import { Request, Response } from 'express'; +import { ERC721Document } from '@thxnetwork/api/models/ERC721'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; + +const validation = []; + +const controller = async (req: Request, res: Response) => { + const result = await ERC721Service.findBySub(req.auth.sub); + res.json(result.map((erc721: ERC721Document) => erc721._id)); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc721/metadata/delete.controller.ts b/apps/api/src/app/controllers/erc721/metadata/delete.controller.ts new file mode 100644 index 000000000..59fd840b7 --- /dev/null +++ b/apps/api/src/app/controllers/erc721/metadata/delete.controller.ts @@ -0,0 +1,13 @@ +import { param } from 'express-validator'; +import { Request, Response } from 'express'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; + +const validation = [param('metadataId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['ERC721 Metadata'] + await ERC721Service.deleteMetadata(req.params.metadataId); + res.status(200).json({ success: true }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc721/metadata/erc721-metadata.test.ts b/apps/api/src/app/controllers/erc721/metadata/erc721-metadata.test.ts new file mode 100644 index 000000000..8fb8a2c4f --- /dev/null +++ b/apps/api/src/app/controllers/erc721/metadata/erc721-metadata.test.ts @@ -0,0 +1,129 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { ChainId } from '@thxnetwork/common/enums'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { dashboardAccessToken } from '@thxnetwork/api/util/jest/constants'; +import { createArchiver } from '@thxnetwork/api/util/zip'; +import { createImage } from '@thxnetwork/api/util/jest/images'; + +const user = request.agent(app); + +describe('ERC721 Metadata', () => { + let erc721ID: string, metadataId: string; + const chainId = ChainId.Hardhat, + name = 'Planets of the Galaxy', + symbol = 'GLXY', + description = 'Collection full of rarities.'; + + beforeAll(beforeAllCallback); + afterAll(afterAllCallback); + + describe('POST /erc721', () => { + it('should create and return contract details', (done) => { + user.post('/v1/erc721') + .set('Authorization', dashboardAccessToken) + .send({ + chainId, + name, + symbol, + description, + }) + .expect(({ body }: request.Response) => { + expect(body._id).toBeDefined(); + expect(body.address).toBeDefined(); + erc721ID = body._id; + }) + .expect(201, done); + }); + }); + + describe('POST /erc721/:id/metadata', () => { + const name = 'red', + description = 'large', + imageUrl = 'http://imageURL.com', + externalUrl = 'http://externalurl.com'; + + it('HTTP 201', (done) => { + user.post('/v1/erc721/' + erc721ID + '/metadata') + .set('Authorization', dashboardAccessToken) + .send({ + name, + description, + imageUrl, + externalUrl, + }) + .expect(({ body }: request.Response) => { + expect(body._id).toBeDefined(); + expect(body.name).toBe(name); + expect(body.description).toBe(description); + expect(body.image).toBe(imageUrl); + expect(body.imageUrl).toBe(imageUrl); + expect(body.externalUrl).toBe(externalUrl); + metadataId = body._id; + }) + .expect(201, done); + }); + }); + + describe('PATCH /metadata/:metadataId', () => { + const value1 = 'blue', + value2 = 'small', + value3 = 'http://imageURL2.com', + value4 = 'http://externalurl2.com'; + + it('should return modified metadata for metadataId', (done) => { + user.patch('/v1/erc721/' + erc721ID + '/metadata/' + metadataId) + .set('Authorization', dashboardAccessToken) + .send({ + name: value1, + description: value2, + imageUrl: value3, + externalUrl: value4, + }) + .expect(({ body }: request.Response) => { + expect(body.name).toBe(value1); + expect(body.description).toBe(value2); + expect(body.image).toBe(value3); + expect(body.imageUrl).toBe(value3); + expect(body.externalUrl).toBe(value4); + }) + .expect(200, done); + }); + }); + + describe('POST /erc721/:id/metadata/zip', () => { + it('HTTP 201', async () => { + const image1 = createImage(); + const image2 = createImage(); + const image3 = createImage(); + const zip = createArchiver().jsZip; + const zipFolder = zip.folder('testImages'); + zipFolder.file('image1.jpg', image1, { binary: true }); + zipFolder.file('image2.jpg', image2, { binary: true }); + zipFolder.file('image3.jpg', image3, { binary: true }); + + const zipFile = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }); + await user + .post('/v1/erc721/' + erc721ID + '/metadata/zip') + .set('Authorization', dashboardAccessToken) + .attach('file', zipFile, { filename: 'images.zip', contentType: 'application/zip' }) + .field({ + description, + propName: 'image', + }) + .expect(201); + }); + }); + + describe('GET /metadata', () => { + it('HTTP 200', (done) => { + user.get('/v1/erc721/' + erc721ID + '/metadata') + .set('Authorization', dashboardAccessToken) + .expect(({ body }: request.Response) => { + expect(body.results.length).toBe(4); + expect(body.total).toBe(4); + }) + .expect(200, done); + }); + }); +}); diff --git a/apps/api/src/app/controllers/erc721/metadata/get.controller.ts b/apps/api/src/app/controllers/erc721/metadata/get.controller.ts new file mode 100644 index 000000000..b49b6c279 --- /dev/null +++ b/apps/api/src/app/controllers/erc721/metadata/get.controller.ts @@ -0,0 +1,13 @@ +import { param } from 'express-validator'; +import { Request, Response } from 'express'; +import { ERC721Metadata } from '@thxnetwork/api/models/ERC721Metadata'; + +const validation = [param('id').isMongoId(), param('metadataId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['ERC721 Metadata'] + const metadata = await ERC721Metadata.findById(req.params.metadataId); + res.json(metadata); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc721/metadata/images/post.controller.ts b/apps/api/src/app/controllers/erc721/metadata/images/post.controller.ts new file mode 100644 index 000000000..5aa02cf86 --- /dev/null +++ b/apps/api/src/app/controllers/erc721/metadata/images/post.controller.ts @@ -0,0 +1,102 @@ +import { AWS_S3_PUBLIC_BUCKET_NAME, IPFS_BASE_URL, NODE_ENV } from '@thxnetwork/api/config/secrets'; +import { ERC721Metadata } from '@thxnetwork/api/models/ERC721Metadata'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; +import ImageService from '@thxnetwork/api/services/ImageService'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { logger } from '@thxnetwork/api/util/logger'; +import { s3Client } from '@thxnetwork/api/util/s3'; +import { createArchiver } from '@thxnetwork/api/util/zip'; +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { Request, Response } from 'express'; +import { body, check, param } from 'express-validator'; +import short from 'short-uuid'; +import IPFSService from '@thxnetwork/api/services/IPFSService'; + +const validation = [ + param('id').isMongoId(), + body('propName').exists().isString(), + check('file').custom((value, { req }) => { + switch (req.file.mimetype) { + case 'application/octet-stream': + case 'application/zip': + case 'application/rar': + return true; + default: + return false; + } + }), +]; + +function parseFilename(filename: string, extension: string) { + return filename.toLowerCase().split(' ').join('-').split('.') + '-' + short.generate() + `.${extension}`; +} + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['ERC721'] + const erc721 = await ERC721Service.findById(req.params.id); + if (!erc721) throw new NotFoundError('Could not find this NFT in the database'); + + const zip = createArchiver().jsZip; + const contents = await zip.loadAsync(req.file.buffer); + + for (const fileName of Object.keys(contents.files)) { + try { + const extension = fileName.substring(fileName.lastIndexOf('.')).substring(1); + if (!extension) continue; + + const originalFileName = fileName.substring(0, fileName.lastIndexOf('.')); + if (!isValidExtension(extension)) continue; + + const file = await zip.file(fileName).async('nodebuffer'); + if (!(await isValidFileType(file))) continue; + + const filename = parseFilename(originalFileName, extension); + await s3Client.send( + new PutObjectCommand({ + Key: filename, + Bucket: AWS_S3_PUBLIC_BUCKET_NAME, + ACL: 'public-read', + Body: file, + }), + ); + + const imageUrl = req.file && (await ImageService.upload(req.file)); + let image = imageUrl; + if (NODE_ENV === 'production') { + const cid = await IPFSService.addUrlSource(imageUrl); + image = IPFS_BASE_URL + cid; + } + + await ERC721Metadata.create({ + erc721Id: String(erc721._id), + name: req.body.name, + description: req.body.description, + externalUrl: req.body.externalUrl, + image, + imageUrl, + }); + } catch (err) { + console.log(err); + logger.error(err); + } + } + + res.status(201).end(); +}; + +function isValidExtension(extension: string) { + return ['jpg', 'jpeg', 'gif', 'png'].includes(extension); +} + +async function isValidFileType(buffer: Buffer) { + const { fileTypeFromBuffer } = await import('file-type'); + const { mime } = await fileTypeFromBuffer(buffer); + + if (!['image/jpeg', 'image/png', 'image/gif'].includes(mime)) { + return false; + } + + return true; +} + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc721/metadata/list.controller.ts b/apps/api/src/app/controllers/erc721/metadata/list.controller.ts new file mode 100644 index 000000000..995da7973 --- /dev/null +++ b/apps/api/src/app/controllers/erc721/metadata/list.controller.ts @@ -0,0 +1,24 @@ +import { Request, Response } from 'express'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; +import { param, query } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; + +const validation = [ + param('id').isMongoId(), + query('limit').optional().isInt({ gt: 0 }), + query('page').optional().isInt({ gt: 0 }), +]; + +const controller = async (req: Request, res: Response) => { + const erc721 = await ERC721Service.findById(req.params.id); + if (!erc721) throw new NotFoundError('Could not find this NFT in the database'); + + const result = await ERC721Service.findMetadataByNFT( + erc721._id, + req.query.page ? Number(req.query.page) : null, + req.query.limit ? Number(req.query.limit) : null, + ); + res.json(result); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc721/metadata/patch.controller.ts b/apps/api/src/app/controllers/erc721/metadata/patch.controller.ts new file mode 100644 index 000000000..07d10cbd1 --- /dev/null +++ b/apps/api/src/app/controllers/erc721/metadata/patch.controller.ts @@ -0,0 +1,45 @@ +import { Request, Response } from 'express'; +import { body, param } from 'express-validator'; +import { BadRequestError, NotFoundError } from '@thxnetwork/api/util/errors'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; +import IPFSService from '@thxnetwork/api/services/IPFSService'; +import { IPFS_BASE_URL, NODE_ENV } from '@thxnetwork/api/config/secrets'; +import { ERC721Metadata } from '@thxnetwork/api/models/ERC721Metadata'; + +const validation = [ + param('id').isMongoId(), + param('metadataId').isMongoId(), + body('name').optional().isString(), + body('description').optional().isString(), + body('externalUrl').optional().isURL(), + body('imageUrl').optional().isURL(), +]; + +const controller = async (req: Request, res: Response) => { + const erc721 = await ERC721Service.findById(req.params.id); + if (!erc721) throw new NotFoundError('Could not find this NFT in the database'); + + const metadata = await ERC721Metadata.findById(req.params.metadataId); + if (!metadata) throw new NotFoundError('Could not find this NFT Metadata in the database'); + + const tokens = metadata.tokens || []; + if (tokens.length) throw new BadRequestError('There token minted with this metadata'); + + let image = req.body.imageUrl; + if (req.body.imageUrl && NODE_ENV === 'production') { + const cid = await IPFSService.addUrlSource(req.body.imageUrl); + image = IPFS_BASE_URL + cid; + } + + metadata.name = req.body.name || metadata.name; + metadata.image = image || metadata.image; + metadata.imageUrl = req.body.imageUrl || metadata.imageUrl; + metadata.description = req.body.description || metadata.description; + metadata.externalUrl = req.body.externalUrl || metadata.externalUrl; + + await metadata.save(); + + res.json({ ...metadata.toJSON(), tokens }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc721/metadata/post.controller.ts b/apps/api/src/app/controllers/erc721/metadata/post.controller.ts new file mode 100644 index 000000000..a37dfdb2f --- /dev/null +++ b/apps/api/src/app/controllers/erc721/metadata/post.controller.ts @@ -0,0 +1,39 @@ +import { Request, Response } from 'express'; +import { body, param } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { ERC721Metadata } from '@thxnetwork/api/models/ERC721Metadata'; +import { IPFS_BASE_URL, NODE_ENV } from '@thxnetwork/api/config/secrets'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; +import IPFSService from '@thxnetwork/api/services/IPFSService'; + +const validation = [ + param('id').isMongoId(), + body('name').optional().isString(), + body('imageUrl').optional().isURL(), + body('description').optional().isString(), + body('externalUrl').optional().isURL(), +]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['ERC721'] + const erc721 = await ERC721Service.findById(req.params.id); + if (!erc721) throw new NotFoundError('Could not find this NFT in the database'); + + let image = req.body.imageUrl; + if (req.body.imageUrl && NODE_ENV === 'production') { + const cid = await IPFSService.addUrlSource(req.body.imageUrl); + image = IPFS_BASE_URL + cid; + } + + const metadata = await ERC721Metadata.create({ + erc721Id: String(erc721._id), + name: req.body.name, + image, + imageUrl: req.body.imageUrl, + description: req.body.description, + externalUrl: req.body.externalUrl, + }); + + res.status(201).json(metadata); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc721/patch.controller.ts b/apps/api/src/app/controllers/erc721/patch.controller.ts new file mode 100644 index 000000000..77b4e9776 --- /dev/null +++ b/apps/api/src/app/controllers/erc721/patch.controller.ts @@ -0,0 +1,19 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { ForbiddenError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { ERC721 } from '@thxnetwork/api/models/ERC721'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['ERC721'] + const erc721 = await ERC721Service.findById(req.params.id); + if (!erc721) throw new NotFoundError('Could not find the token for this id'); + if (erc721.sub !== req.auth.sub) throw new ForbiddenError('Not your ERC721'); + + const result = await ERC721.findByIdAndUpdate(req.params.id, req.body, { new: true }); + + res.json(result); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc721/post.controller.ts b/apps/api/src/app/controllers/erc721/post.controller.ts new file mode 100644 index 000000000..b7c1d67fd --- /dev/null +++ b/apps/api/src/app/controllers/erc721/post.controller.ts @@ -0,0 +1,46 @@ +import { API_URL, IPFS_BASE_URL, VERSION } from '@thxnetwork/api/config/secrets'; +import { Request, Response } from 'express'; +import { body, check, query } from 'express-validator'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; +import ImageService from '@thxnetwork/api/services/ImageService'; +import { AccountPlanType, NFTVariant } from '@thxnetwork/common/enums'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; + +const validation = [ + body('name').exists().isString(), + body('symbol').exists().isString(), + body('description').exists().isString(), + body('chainId').exists().isNumeric(), + check('file') + .optional() + .custom((value, { req }) => { + return ['jpg', 'jpeg', 'gif', 'png'].includes(req.file.mimetype); + }), + query('forceSync').optional().isBoolean(), +]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['ERC721'] + + const logoImgUrl = req.file && (await ImageService.upload(req.file)); + const forceSync = req.query.forceSync !== undefined ? req.query.forceSync === 'true' : false; + const account = await AccountProxy.findById(req.auth.sub); + const baseURL = account.plan === AccountPlanType.Premium ? IPFS_BASE_URL : `${API_URL}/${VERSION}/metadata/`; + const erc721 = await ERC721Service.deploy( + { + variant: NFTVariant.ERC721, + sub: req.auth.sub, + chainId: req.body.chainId, + name: req.body.name, + symbol: req.body.symbol, + description: req.body.description, + baseURL, + logoImgUrl, + }, + forceSync, + ); + + res.status(201).json(erc721); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc721/token/get.controller.ts b/apps/api/src/app/controllers/erc721/token/get.controller.ts new file mode 100644 index 000000000..8f91f5b10 --- /dev/null +++ b/apps/api/src/app/controllers/erc721/token/get.controller.ts @@ -0,0 +1,42 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { fromWei } from 'web3-utils'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { ERC721Metadata } from '@thxnetwork/api/models/ERC721Metadata'; +import { ERC721Token } from '@thxnetwork/api/models/ERC721Token'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const token = await ERC721Token.findById(req.params.id); + if (!token) throw new NotFoundError('ERC721Token not found'); + + const [erc721, metadata] = await Promise.all([ + ERC721Service.findById(token.erc721Id), + ERC721Metadata.findById(token.metadataId), + ]); + if (!erc721) throw new NotFoundError('ERC721 not found'); + if (!metadata) throw new NotFoundError('ERC721Metadata not found'); + + const balanceInWei = await erc721.contract.methods.balanceOf(token.recipient).call(); + const balance = Number(fromWei(balanceInWei, 'ether')); + + const [owner, tokenUri] = token.tokenId + ? await Promise.all([ + erc721.contract.methods.ownerOf(token.tokenId).call(), + erc721.contract.methods.tokenURI(token.tokenId).call(), + ]) + : []; + + res.status(200).json({ + ...token.toJSON(), + owner, + tokenUri, + balance, + nft: erc721.toJSON(), + metadata: metadata.toJSON(), + }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc721/token/list.controller.ts b/apps/api/src/app/controllers/erc721/token/list.controller.ts new file mode 100644 index 000000000..e54b15b81 --- /dev/null +++ b/apps/api/src/app/controllers/erc721/token/list.controller.ts @@ -0,0 +1,30 @@ +import { Request, Response } from 'express'; +import { query } from 'express-validator'; +import { ERC721Token, ERC721TokenDocument, ERC721Metadata } from '@thxnetwork/api/models'; +import { BadRequestError } from '@thxnetwork/api/util/errors'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; +import SafeService from '@thxnetwork/api/services/SafeService'; + +const validation = [query('walletId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const wallet = await SafeService.findById(req.query.walletId as string); + if (!wallet) throw new BadRequestError('Wallet not found'); + + const tokens = await ERC721Token.find({ walletId: wallet.id }); + const result = await Promise.all( + tokens.map(async (token: ERC721TokenDocument) => { + const erc721 = await ERC721Service.findById(token.erc721Id); + if (!erc721) return; + + const metadata = await ERC721Metadata.findById(token.metadataId); + if (!metadata) return; + + return Object.assign(token.toJSON() as TERC721Token, { metadata, tokenUri: token.tokenUri, nft: erc721 }); + }), + ); + + res.json(result.reverse().filter((token) => !!token)); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/erc721/transfer/erc721-transfer.test.ts b/apps/api/src/app/controllers/erc721/transfer/erc721-transfer.test.ts new file mode 100644 index 000000000..451fc22c6 --- /dev/null +++ b/apps/api/src/app/controllers/erc721/transfer/erc721-transfer.test.ts @@ -0,0 +1,191 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { ChainId, NFTVariant } from '@thxnetwork/common/enums'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { sub, sub2, userWalletPrivateKey, widgetAccessToken } from '@thxnetwork/api/util/jest/constants'; +import { poll } from '@thxnetwork/api/util/polling'; +import { signTxHash } from '@thxnetwork/api/util/jest/network'; +import { safeVersion } from '@thxnetwork/api/services/ContractService'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { + ERC721Token, + ERC721TokenDocument, + ERC721, + ERC721Document, + ERC721Metadata, + Wallet, + WalletDocument, + Pool, + PoolDocument, +} from '@thxnetwork/api/models'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import SafeService from '@thxnetwork/api/services/SafeService'; + +const user = request.agent(app); + +describe('ERC721 Transfer', () => { + let erc721: ERC721Document, + erc721Token: ERC721TokenDocument, + pool: PoolDocument, + wallet: WalletDocument, + safeTxHash = ''; + const chainId = ChainId.Hardhat, + name = 'Test Collection', + symbol = 'TST', + baseURL = 'https://example.com', + logoImgUrl = 'https://img.url', + metadataName = 'Testname', + metadataImageUrl = 'Testimageurl', + metadataIPFSImageUrl = 'TestIPFSimageurl', + metadataDescription = 'Testdescription', + metadataExternalUrl = 'TestexternalURL'; + + beforeAll(beforeAllCallback); + afterAll(afterAllCallback); + + it('Deploy Campaign Safe', async () => { + const { web3 } = getProvider(chainId); + pool = await PoolService.deploy(sub, 'My Reward Campaign'); + const safe = await SafeService.create({ chainId, sub, safeVersion, poolId: String(pool._id) }); + + // Wait for safe address to return code + await poll( + () => web3.eth.getCode(safe.address), + (data: string) => data === '0x', + 1000, + ); + const code = await web3.eth.getCode(safe.address); + const result = code !== '0x'; + + expect(result).toBe(true); + }); + + it('Deploy ERC721', async () => { + const { web3 } = getProvider(chainId); + + erc721 = await ERC721Service.deploy( + { + variant: NFTVariant.ERC721, + sub, + chainId, + name, + symbol, + description: '', + baseURL, + archived: false, + logoImgUrl, + }, + true, + ); + + // Wait for nft address to return code + await poll( + async () => (await ERC721.findById(erc721._id)).address, + (address: string) => !address || !address.length, + 1000, + ); + + erc721 = await ERC721.findById(erc721._id); + + const code = await web3.eth.getCode(erc721.address); + const result = code !== '0x'; + expect(result).toBe(true); + }); + + it('Add ERC721 minter', async () => { + pool = await Pool.findById(pool._id); + + const safe = await SafeService.findOneByPool(pool); + erc721 = await ERC721.findById(erc721._id); + + await ERC721Service.addMinter(erc721, safe.address); + + // Wait for nft address to return code + await poll( + async () => await ERC721Service.isMinter(erc721, safe.address), + (isMinter: boolean) => !isMinter, + 1000, + ); + + const isMinter = await ERC721Service.isMinter(erc721, safe.address); + expect(isMinter).toBe(true); + }); + + it('Create ERC721 Metadata', async () => { + // Create metadata for token + const metadata = await ERC721Metadata.create({ + erc721Id: String(erc721._id), + name: metadataName, + image: metadataIPFSImageUrl, + imageUrl: metadataImageUrl, + description: metadataDescription, + externalUrl: metadataExternalUrl, + }); + const safe = await SafeService.findOneByPool(pool); + + // Wait for safe address to return code + const { web3 } = getProvider(chainId); + await poll( + () => web3.eth.getCode(safe.address), + (data: string) => data === '0x', + 1000, + ); + + wallet = await SafeService.findOne({ sub, safeVersion: { $exists: true } }); + + // Mint a token for metadata + erc721Token = await ERC721Service.mint(safe, erc721, wallet, metadata); + + // Wait for tokenId to be set in mint callback + await poll( + async () => (await ERC721Token.findById(erc721Token._id)).tokenId, + (tokenId?: number) => typeof tokenId === 'undefined', + 1000, + ); + + erc721Token = await ERC721Token.findById(erc721Token._id); + + expect(erc721Token.tokenId).toBeDefined(); + }); + + it('Transfer ERC721 ownership', async () => { + const receiver = await Wallet.findOne({ sub: sub2, safeVersion }); + const { status, body } = await user + .post('/v1/erc721/transfer') + .set({ Authorization: widgetAccessToken }) + .send({ + walletId: String(wallet._id), + erc721Id: erc721._id, + erc721TokenId: erc721Token._id, + to: receiver.address, + }); + + expect(status).toBe(201); + expect(body.safeTxHash).toBeDefined(); + + safeTxHash = body.safeTxHash; + }); + + it('Confirm tx', async () => { + const wallet = await SafeService.findOne({ sub, safeVersion: { $exists: true } }); + const { signature } = await signTxHash(wallet.address, safeTxHash, userWalletPrivateKey); + const { status, body } = await user + .post(`/v1/account/wallets/confirm`) + .set({ Authorization: widgetAccessToken }) + .query({ walletId: String(wallet._id) }) + .send({ chainId: ChainId.Hardhat, safeTxHash, signature }); + expect(status).toBe(200); + }); + + it('Wait for ownerOf', async () => { + const receiver = await Wallet.findOne({ sub: sub2, safeVersion }); + const token = await ERC721Token.findById(erc721Token._id); + const { contract } = await ERC721.findById(erc721._id); + + await poll(contract.methods.ownerOf(token.tokenId).call, (result: string) => result !== receiver.address, 1000); + + const owner = await contract.methods.ownerOf(token.tokenId).call(); + expect(owner).toEqual(receiver.address); + }); +}); diff --git a/apps/api/src/app/controllers/erc721/transfer/post.controller.ts b/apps/api/src/app/controllers/erc721/transfer/post.controller.ts new file mode 100644 index 000000000..a2c7f22e9 --- /dev/null +++ b/apps/api/src/app/controllers/erc721/transfer/post.controller.ts @@ -0,0 +1,35 @@ +import { Request, Response } from 'express'; +import { body } from 'express-validator'; +import { ForbiddenError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { ERC721Token } from '@thxnetwork/api/models/ERC721Token'; +import { ERC721 } from '@thxnetwork/api/models/ERC721'; +import { Transaction } from '@thxnetwork/api/models/Transaction'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; +import SafeService from '@thxnetwork/api/services/SafeService'; + +const validation = [ + body('walletId').isMongoId(), + body('erc721Id').isMongoId(), + body('erc721TokenId').isMongoId(), + body('to').isString(), +]; + +const controller = async (req: Request, res: Response) => { + const erc721 = await ERC721.findById(req.body.erc721Id); + if (!erc721) throw new NotFoundError('Could not find the ERC721'); + + const erc721Token = await ERC721Token.findById(req.body.erc721TokenId); + if (!erc721Token) throw new NotFoundError('Could not find token for wallet'); + + const wallet = await SafeService.findById(req.body.walletId); + if (!wallet) throw new NotFoundError('Could not find wallet for account'); + + const owner = await erc721.contract.methods.ownerOf(erc721Token.tokenId).call(); + if (owner !== wallet.address) throw new ForbiddenError('Account is not owner of given tokenId'); + + const receiverToken = await ERC721Service.transferFrom(erc721, wallet, req.body.to, erc721Token); + const tx = await Transaction.findById(receiverToken.transactions[0]); + + res.status(201).json(tx); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/events/events.router.ts b/apps/api/src/app/controllers/events/events.router.ts new file mode 100644 index 000000000..b82941b96 --- /dev/null +++ b/apps/api/src/app/controllers/events/events.router.ts @@ -0,0 +1,9 @@ +import express from 'express'; +import * as CreateEvents from './post.controller'; +import { assertRequestInput, guard } from '@thxnetwork/api/middlewares'; + +const router: express.Router = express.Router(); + +router.post('/', guard.check(['events:write']), assertRequestInput(CreateEvents.validation), CreateEvents.controller); + +export default router; diff --git a/apps/api/src/app/controllers/events/post.controller.ts b/apps/api/src/app/controllers/events/post.controller.ts new file mode 100644 index 000000000..a6c806b00 --- /dev/null +++ b/apps/api/src/app/controllers/events/post.controller.ts @@ -0,0 +1,24 @@ +import { Request, Response } from 'express'; +import { body } from 'express-validator'; +import { Pool, Event, Identity, Client } from '@thxnetwork/api/models'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; + +const validation = [body('event').isString().isLength({ min: 0, max: 50 }), body('identityUuid').isUUID()]; + +const controller = async (req: Request, res: Response) => { + const { identityUuid, event } = req.body; + const client = await Client.findOne({ clientId: req.auth.client_id }); + if (!client) throw new NotFoundError('Could not find client for token'); + + const pool = await Pool.findById(client.poolId); + if (!pool) throw new NotFoundError('Could not find pool for client'); + + const identity = await Identity.findOne({ uuid: identityUuid }); + if (!identity) throw new NotFoundError('Could not find ID for uuid'); + + await Event.create({ name: event, poolId: pool._id, identityId: identity._id }); + + res.status(201).end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/health/health.router.ts b/apps/api/src/app/controllers/health/health.router.ts new file mode 100644 index 000000000..c973b0469 --- /dev/null +++ b/apps/api/src/app/controllers/health/health.router.ts @@ -0,0 +1,8 @@ +import express from 'express'; +import * as ListHealth from './list.controller'; + +const router: express.Router = express.Router(); + +router.get('/', ListHealth.controller); + +export default router; diff --git a/apps/api/src/app/controllers/health/list.controller.ts b/apps/api/src/app/controllers/health/list.controller.ts new file mode 100644 index 000000000..16d197f04 --- /dev/null +++ b/apps/api/src/app/controllers/health/list.controller.ts @@ -0,0 +1,146 @@ +import { Request, Response } from 'express'; +import newrelic from 'newrelic'; +import { fromWei } from 'web3-utils'; +import { NODE_ENV } from '@thxnetwork/api/config/secrets'; +import { ChainId } from '@thxnetwork/common/enums'; +import { logger } from '@thxnetwork/api/util/logger'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { BigNumber, ethers } from 'ethers'; +import { contractArtifacts } from '@thxnetwork/api/services/ContractService'; +import { contractNetworks } from '@thxnetwork/api/contracts'; + +function handleError(error: Error) { + newrelic.noticeError(error); + logger.error(error); + return { error: 'invalid response' }; +} + +async function getNetworkDetails(chainId: ChainId) { + try { + const { defaultAccount, web3, signer } = getProvider(chainId); + const rfthx = new ethers.Contract( + contractNetworks[chainId].RewardFaucet, + contractArtifacts['RewardFaucet'].abi, + signer, + ); + const registry = new ethers.Contract( + contractNetworks[chainId].THXRegistry, + contractArtifacts['THXRegistry'].abi, + signer, + ); + const rdthx = new ethers.Contract( + contractNetworks[chainId].RewardDistributor, + contractArtifacts['RewardDistributor'].abi, + signer, + ); + const bpt = new ethers.Contract(contractNetworks[chainId].BPT, contractArtifacts['BPT'].abi, signer); + const bptGauge = new ethers.Contract( + contractNetworks[chainId].BPTGauge, + contractArtifacts['BPTGauge'].abi, + signer, + ); + const veTHX = new ethers.Contract( + contractNetworks[chainId].VotingEscrow, + contractArtifacts['VotingEscrow'].abi, + signer, + ); + const bal = new ethers.Contract(contractNetworks[chainId].BAL, contractArtifacts['BAL'].abi, signer); + // const thx = new ethers.Contract(contractNetworks[chainId].THX, contractArtifacts['THX'].abi, signer); + // const usdc = new ethers.Contract(contractNetworks[chainId].USDC, contractArtifacts['USDC'].abi, signer); + + const address = { + registry: registry.address, + relayer: defaultAccount, + bptGauge: bptGauge.address, + bpt: bpt.address, + bal: bal.address, + thx: contractNetworks[chainId].THX, + usdc: contractNetworks[chainId].USDC, + vault: contractNetworks[chainId].BalancerVault, + }; + + const relayer = await Promise.all([ + { + matic: fromWei(String(await web3.eth.getBalance(defaultAccount)), 'ether'), + bpt: fromWei(String(await bpt.balanceOf(defaultAccount)), 'ether'), + // bptGauge: fromWei(String(await bptGauge.balanceOf(defaultAccount)), 'ether'), + // bal: fromWei(String(await bal.balanceOf(defaultAccount)), 'ether'), + // thx: fromWei(String(await thx.balanceOf(defaultAccount)), 'ether'), + // usdc: fromWei(String(await usdc.balanceOf(defaultAccount)), 'ether'), + }, + ]); + const total = fromWei(String(await rfthx.totalTokenRewards(bpt.address)), 'ether'); + const currentBlock = await web3.eth.getBlock('latest'); + const amountStaked = BigNumber.from(String(await bpt.balanceOf(bptGauge.address))); + const amountSupply = BigNumber.from(String(await bpt.totalSupply())); + const amountUnstaked = amountSupply.sub(amountStaked); + const amountLocked = await bptGauge.balanceOf(veTHX.address); + + const metrics = { + unstaked: fromWei(amountUnstaked.toString(), 'ether'), + staked: fromWei(amountStaked.toString(), 'ether'), + locked: fromWei(amountLocked.toString(), 'ether'), + }; + const getRewards = async (tokenAddress: string, now: string) => { + const currentWeek = fromWei(String(await rfthx.getTokenWeekAmounts(tokenAddress, now))); + const upcomingWeeks = (await rfthx.getUpcomingRewardsForNWeeks(tokenAddress, 4)).map((amount: BigNumber) => + fromWei(String(amount)), + ); + return [currentWeek, ...upcomingWeeks]; + }; + const distributor = { + total, + balances: await Promise.all([ + { + bpt: fromWei(String(await bpt.balanceOf(rfthx.address)), 'ether'), + bal: fromWei(String(await bal.balanceOf(rfthx.address)), 'ether'), + }, + ]), + rewards: { + bpt: await getRewards(bpt.address, String(currentBlock.timestamp)), + bal: await getRewards(bal.address, String(currentBlock.timestamp)), + }, + }; + const splitter = new ethers.Contract( + contractNetworks[chainId].THXPaymentSplitter, + contractArtifacts['THXPaymentSplitter'].abi, + signer, + ); + + return { + blockTime: new Date(Number(currentBlock.timestamp) * 1000), + registry: { + payoutRate: BigNumber.from(await registry.getPayoutRate()) + .div(100) + .toString(), + payee: await registry.getPayee(), + }, + test: { + rate: (await splitter.rates('0x029E2d4D2b6938c92c48dbf422a4e500425a08D8')).toString(), + balance: (await splitter.balanceOf('0x029E2d4D2b6938c92c48dbf422a4e500425a08D8')).toString(), + }, + address, + relayer, + metrics, + distributor, + }; + } catch (error) { + return handleError(error); + } +} + +const controller = async (req: Request, res: Response) => { + const result = { + networks: {}, + }; + + if (NODE_ENV !== 'production') { + result.networks[ChainId.Hardhat] = await getNetworkDetails(ChainId.Hardhat); + } else { + result.networks[ChainId.Polygon] = await getNetworkDetails(ChainId.Polygon); + } + + res.header('Content-Type', 'application/json').send(JSON.stringify(result, null, 4)); +}; + +export { controller }; diff --git a/apps/api/src/app/controllers/identity/get.controller.ts b/apps/api/src/app/controllers/identity/get.controller.ts new file mode 100644 index 000000000..fccd85df0 --- /dev/null +++ b/apps/api/src/app/controllers/identity/get.controller.ts @@ -0,0 +1,21 @@ +import { Request, Response } from 'express'; +import { Pool, Client } from '@thxnetwork/api/models'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { param } from 'express-validator'; +import IdentityService from '@thxnetwork/api/services/IdentityService'; + +const validation = [param('salt').isString().isLength({ min: 0 })]; + +const controller = async (req: Request, res: Response) => { + const client = await Client.findOne({ clientId: req.auth.client_id }); + if (!client) throw new NotFoundError('Could not find client for token'); + + const pool = await Pool.findById(client.poolId); + if (!pool) throw new NotFoundError('Could not find pool for client'); + + const identity = await IdentityService.getIdentityForSalt(pool, req.params.salt); + + res.json(identity.uuid); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/identity/identity.router.ts b/apps/api/src/app/controllers/identity/identity.router.ts new file mode 100644 index 000000000..832a074e8 --- /dev/null +++ b/apps/api/src/app/controllers/identity/identity.router.ts @@ -0,0 +1,23 @@ +import express from 'express'; +import * as CreateController from './post.controller'; +import * as UpdateController from './patch.controller'; +import * as ReadController from './get.controller'; +import { assertRequestInput, guard } from '@thxnetwork/api/middlewares'; + +const router: express.Router = express.Router(); + +router.patch('/:uuid', assertRequestInput(UpdateController.validation), UpdateController.controller); +router.get( + '/:salt', + guard.check(['identities:read']), + assertRequestInput(ReadController.validation), + ReadController.controller, +); +router.post( + '/', + guard.check(['identities:write']), + assertRequestInput(CreateController.validation), + CreateController.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/identity/patch.controller.ts b/apps/api/src/app/controllers/identity/patch.controller.ts new file mode 100644 index 000000000..07a5de856 --- /dev/null +++ b/apps/api/src/app/controllers/identity/patch.controller.ts @@ -0,0 +1,24 @@ +import { Request, Response } from 'express'; +import { Pool, Identity } from '@thxnetwork/api/models'; +import { ForbiddenError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { param } from 'express-validator'; + +const validation = [param('uuid').isUUID()]; + +const controller = async (req: Request, res: Response) => { + const pool = await Pool.findById(req.header('X-PoolId')); + if (!pool) throw new NotFoundError('Pool not found.'); + + const { uuid } = req.params; + const { sub } = req.auth; + + // Throw if Identity is connected already + const isConnected = await Identity.exists({ uuid, sub: { $exists: true } }); + if (isConnected) throw new ForbiddenError('Identity already connected.'); + + const identity = await Identity.findOneAndUpdate({ uuid }, { sub }, { new: true }); + + res.json(identity); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/identity/post.controller.ts b/apps/api/src/app/controllers/identity/post.controller.ts new file mode 100644 index 000000000..375901c07 --- /dev/null +++ b/apps/api/src/app/controllers/identity/post.controller.ts @@ -0,0 +1,21 @@ +import { Request, Response } from 'express'; +import { Pool, Identity, Client } from '@thxnetwork/api/models'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { uuidV1 } from '@thxnetwork/api/util/uuid'; + +const validation = []; + +const controller = async (req: Request, res: Response) => { + const client = await Client.findOne({ clientId: req.auth.client_id }); + if (!client) throw new NotFoundError('Could not find client for token'); + + const pool = await Pool.findById(client.poolId); + if (!pool) throw new NotFoundError('Could not find pool for client'); + + const uuid = uuidV1(); + const id = await Identity.create({ poolId: pool._id, uuid }); + + res.json(id.uuid); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/index.ts b/apps/api/src/app/controllers/index.ts new file mode 100644 index 000000000..73251ba9b --- /dev/null +++ b/apps/api/src/app/controllers/index.ts @@ -0,0 +1,67 @@ +import express from 'express'; +import RouterHealth from './health/health.router'; +import RouterAccount from './account/account.router'; +import RouterPools from './pools/pools.router'; +import RouterToken from './token/token.router'; +import RouterParticipants from './participants/participants.router'; +import RouterMetadata from './metadata/metadata.router'; +import RouterUpload from './upload/upload.router'; +import RouterERC20 from './erc20/erc20.router'; +import RouterERC721 from './erc721/erc721.router'; +import RouterERC1155 from './erc1155/erc1155.router'; +import RouterClients from './client/client.router'; +import RouterQRCodes from './qr-codes/qr-codes.router'; +import RouterBrands from './brands/brands.router'; +import RouterWidget from './widget/widget.router'; +import RouterQuests from './quests/quests.router'; +import RouterRewards from './rewards/rewards.router'; +import RouterLeaderboards from './leaderboards/leaderboards.router'; +import RouterWebhook from './webhook/webhook.router'; +import RouterWebhooks from './webhooks/webhooks.router'; +import RouterPrices from './earn/earn.router'; +import RouterWidgets from './widgets/widgets.router'; +import RouterIdentity from './identity/identity.router'; +import RouterEvents from './events/events.router'; +import RouterData from './data/data.router'; +import RouterLiquidity from './liquidity/liquidity.router'; +import RouterVoteEscrow from './ve/ve.router'; +import RouterJobs from './jobs/jobs.router'; +import RouterCoupons from './coupons/coupons.router'; +import { checkJwt, corsHandler } from '@thxnetwork/api/middlewares'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.use('/ping', (_req, res) => res.send('pong')); +router.use('/health', RouterHealth); +router.use('/data', RouterData); +router.use('/token', RouterToken); +router.use('/metadata', RouterMetadata); +router.use('/brands', RouterBrands); +router.use('/widget', RouterWidget); +router.use('/leaderboards', RouterLeaderboards); +router.use('/claims', RouterQRCodes); // Legacy QR codes still redirect to /claims/r/:uuid +router.use('/qr-codes', RouterQRCodes); +router.use('/quests', RouterQuests); +router.use('/rewards', RouterRewards); +router.use('/webhook', RouterWebhook); +router.use('/earn', RouterPrices); +router.use(checkJwt, corsHandler); +router.use('/jobs', RouterJobs); +router.use('/upload', RouterUpload); +router.use('/identity', RouterIdentity); +router.use('/events', RouterEvents); +router.use('/coupons', RouterCoupons); +router.use('/account', RouterAccount); +router.use('/participants', RouterParticipants); +router.use('/pools', RouterPools); +router.use('/widgets', RouterWidgets); +router.use('/clients', RouterClients); +router.use('/webhooks', RouterWebhooks); +router.use('/ve', RouterVoteEscrow); +router.use('/liquidity', RouterLiquidity); + +router.use('/erc20', RouterERC20); +router.use('/erc721', RouterERC721); +router.use('/erc1155', RouterERC1155); + +export { router }; diff --git a/apps/api/src/app/controllers/jobs/get.controller.ts b/apps/api/src/app/controllers/jobs/get.controller.ts new file mode 100644 index 000000000..38a5a45af --- /dev/null +++ b/apps/api/src/app/controllers/jobs/get.controller.ts @@ -0,0 +1,12 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { Job } from '@thxnetwork/api/models/Job'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const job = await Job.findById(req.params.id); + res.json(job); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/jobs/jobs.router.ts b/apps/api/src/app/controllers/jobs/jobs.router.ts new file mode 100644 index 000000000..a2d23db45 --- /dev/null +++ b/apps/api/src/app/controllers/jobs/jobs.router.ts @@ -0,0 +1,16 @@ +import express from 'express'; +import * as ReadJobs from './get.controller'; +import { assertRequestInput, guard } from '@thxnetwork/api/middlewares'; + +const router: express.Router = express.Router(); + +router.get( + '/:id', + guard.check([ + // 'jobs:read' + ]), + assertRequestInput(ReadJobs.validation), + ReadJobs.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/leaderboards/get.controller.ts b/apps/api/src/app/controllers/leaderboards/get.controller.ts new file mode 100644 index 000000000..4416f5bfe --- /dev/null +++ b/apps/api/src/app/controllers/leaderboards/get.controller.ts @@ -0,0 +1,25 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import PoolService from '@thxnetwork/api/services/PoolService'; + +const validation = [param('campaignId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const pool = await PoolService.getById(req.params.campaignId); + const leaderboard = await PoolService.findParticipants(pool, 1, 10); + const result = leaderboard.results.map((p) => { + return { + rank: p.rank, + account: { + username: p.account && p.account.username, + profileImg: p.account && p.account.profileImg, + }, + questsCompleted: p.questEntryCount, + score: p.score, + }; + }); + + res.json(result); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/leaderboards/leaderboards.router.ts b/apps/api/src/app/controllers/leaderboards/leaderboards.router.ts new file mode 100644 index 000000000..cd1648e4a --- /dev/null +++ b/apps/api/src/app/controllers/leaderboards/leaderboards.router.ts @@ -0,0 +1,11 @@ +import express from 'express'; +import { assertRequestInput } from '@thxnetwork/api/middlewares'; +import * as ListLeaderboard from './list.controller'; +import * as ReadLeaderboard from './get.controller'; + +export const router: express.Router = express.Router(); + +router.get('/', assertRequestInput(ListLeaderboard.validation), ListLeaderboard.controller); +router.get('/:campaignId', assertRequestInput(ReadLeaderboard.validation), ReadLeaderboard.controller); + +export default router; diff --git a/apps/api/src/app/controllers/leaderboards/list.controller.ts b/apps/api/src/app/controllers/leaderboards/list.controller.ts new file mode 100644 index 000000000..990bf9649 --- /dev/null +++ b/apps/api/src/app/controllers/leaderboards/list.controller.ts @@ -0,0 +1,67 @@ +import { Request, Response } from 'express'; +import { Pool, PoolDocument, Brand } from '@thxnetwork/api/models'; +import { Widget } from '@thxnetwork/api/models/Widget'; +import { query } from 'express-validator'; +import { Participant } from '@thxnetwork/api/models/Participant'; +import RewardService from '@thxnetwork/api/services/RewardService'; +import QuestService from '@thxnetwork/api/services/QuestService'; + +const matchTitle = (search) => { + if (!search || !search.length) return; + return new RegExp( + search + .split(/\s+/) + .map((word) => `(?=.*${word})`) + .join(''), + 'i', + ); +}; + +export const paginatedResults = async (page: number, limit: number, search: string) => { + const startIndex = (page - 1) * limit; + const $match = { + 'rank': { $exists: true }, + 'settings.isPublished': true, + ...(search && { 'settings.title': matchTitle(search) }), + }; + const total = await Pool.countDocuments($match); + const results = await Pool.find($match).sort({ rank: 1 }).skip(startIndex).limit(limit); + + return { page, total, limit, results }; +}; + +const validation = [query('page').isInt(), query('limit').isInt(), query('search').optional().isString()]; + +const controller = async (req: Request, res: Response) => { + const { page, limit, search } = req.query; + const result = await paginatedResults(Number(page), Number(limit), search ? String(search) : ''); + const widgets = await Widget.find({ poolId: result.results.map((p: PoolDocument) => p._id) }); + const brands = await Brand.find({ poolId: result.results.map((p: PoolDocument) => p._id) }); + + result.results = (await Promise.all( + result.results.map(async (pool) => { + const widget = widgets.find((w) => w.poolId === String(pool._id)); + const brand = brands.find((b) => b.poolId === String(pool._id)); + const participantCount = await Participant.countDocuments({ poolId: pool._id }); + const questCount = await QuestService.count({ poolId: pool._id }); + const rewardCount = await RewardService.count({ poolId: pool._id }); + return { + _id: pool._id, + rank: pool.rank, + slug: pool.settings.slug || pool._id, + title: pool.settings.title, + domain: widget ? widget.domain : 'https://app.thx.network', + logoImgUrl: brand && brand.logoImgUrl, + backgroundImgUrl: brand && brand.backgroundImgUrl, + participantCount, + questCount, + rewardCount, + createdAt: pool.createdAt, + }; + }), + )) as any; + + res.json(result); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/liquidity/liquidity.router.ts b/apps/api/src/app/controllers/liquidity/liquidity.router.ts new file mode 100644 index 000000000..49a74f5f5 --- /dev/null +++ b/apps/api/src/app/controllers/liquidity/liquidity.router.ts @@ -0,0 +1,12 @@ +import express from 'express'; +import { assertWallet, assertRequestInput } from '@thxnetwork/api/middlewares'; +import * as CreateLiquidity from './post.controller'; +import * as CreateLiquidityStaked from './stake/post.controller'; + +const router: express.Router = express.Router(); + +router.use('/', assertWallet); +router.post('/', assertRequestInput(CreateLiquidity.validation), CreateLiquidity.controller); +router.post('/stake', assertRequestInput(CreateLiquidityStaked.validation), CreateLiquidityStaked.controller); + +export default router; diff --git a/apps/api/src/app/controllers/liquidity/post.controller.ts b/apps/api/src/app/controllers/liquidity/post.controller.ts new file mode 100644 index 000000000..0abffce3e --- /dev/null +++ b/apps/api/src/app/controllers/liquidity/post.controller.ts @@ -0,0 +1,17 @@ +import LiquidityService from '@thxnetwork/api/services/LiquidityService'; +import { Request, Response } from 'express'; +import { query, body } from 'express-validator'; + +const validation = [ + body('usdcAmountInWei').isString(), + body('thxAmountInWei').isString(), + body('slippage').isString(), + query('walletId').isMongoId(), +]; + +const controller = async ({ wallet, body }: Request, res: Response) => { + const tx = await LiquidityService.create(wallet, body.usdcAmountInWei, body.thxAmountInWei, body.slippage); + + res.status(201).json([tx]); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/liquidity/stake/post.controller.ts b/apps/api/src/app/controllers/liquidity/stake/post.controller.ts new file mode 100644 index 000000000..5c4be80e5 --- /dev/null +++ b/apps/api/src/app/controllers/liquidity/stake/post.controller.ts @@ -0,0 +1,26 @@ +import { contractArtifacts } from '@thxnetwork/api/services/ContractService'; +import { BadRequestError } from '@thxnetwork/api/util/errors'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { contractNetworks } from '@thxnetwork/api/contracts'; +import { BigNumber } from 'ethers'; +import { Request, Response } from 'express'; +import { body, query } from 'express-validator'; +import LiquidityService from '@thxnetwork/api/services/LiquidityService'; + +const validation = [body('amountInWei').isString(), query('walletId').isMongoId()]; + +const controller = async ({ wallet, body }: Request, res: Response) => { + const { web3 } = getProvider(wallet.chainId); + const bpt = new web3.eth.Contract(contractArtifacts['BPT'].abi, contractNetworks[wallet.chainId].BPT); + + // Check if sender has sufficient BPT + const balanceInWei = await bpt.methods.balanceOf(wallet.address).call(); + if (BigNumber.from(balanceInWei).lt(body.amountInWei)) { + throw new BadRequestError('Insufficient balance'); + } + + const tx = await LiquidityService.stake(wallet, body.amountInWei); + + res.status(201).json([tx]); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/metadata/erc1155/get.controller.ts b/apps/api/src/app/controllers/metadata/erc1155/get.controller.ts new file mode 100644 index 000000000..a5c128aab --- /dev/null +++ b/apps/api/src/app/controllers/metadata/erc1155/get.controller.ts @@ -0,0 +1,24 @@ +import { param } from 'express-validator'; +import { Request, Response } from 'express'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { ERC1155Metadata } from '@thxnetwork/api/models/ERC1155Metadata'; + +const validation = [param('erc1155Id').isMongoId(), param('tokenId').isInt()]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['ERC1155 Metadata'] + const { erc1155Id, tokenId } = req.params; + const metadata = await ERC1155Metadata.findOne({ erc1155Id, tokenId }); + if (!metadata) throw new NotFoundError('Could not find metadata for this ID'); + + const attributes = { + name: metadata.name, + description: metadata.description, + image: metadata.image, + external_url: metadata.externalUrl, + }; + + res.header('Content-Type', 'application/json').send(JSON.stringify(attributes, null, 4)); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/metadata/get.controller.ts b/apps/api/src/app/controllers/metadata/get.controller.ts new file mode 100644 index 000000000..b89bf0067 --- /dev/null +++ b/apps/api/src/app/controllers/metadata/get.controller.ts @@ -0,0 +1,23 @@ +import { param } from 'express-validator'; +import { Request, Response } from 'express'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { ERC721Metadata } from '@thxnetwork/api/models/ERC721Metadata'; + +const validation = [param('metadataId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['ERC721 Metadata'] + const metadata = await ERC721Metadata.findById(req.params.metadataId); + if (!metadata) throw new NotFoundError('Could not find metadata for this ID'); + + const attributes = { + name: metadata.name, + description: metadata.description, + image: metadata.image, + external_url: metadata.externalUrl, + }; + + res.header('Content-Type', 'application/json').send(JSON.stringify(attributes, null, 4)); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/metadata/metadata.router.ts b/apps/api/src/app/controllers/metadata/metadata.router.ts new file mode 100644 index 000000000..49d43904f --- /dev/null +++ b/apps/api/src/app/controllers/metadata/metadata.router.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import { assertRequestInput } from '@thxnetwork/api/middlewares'; +import * as ReadMetadata from './get.controller'; +import * as ReadERC1155Metadata from './erc1155/get.controller'; + +const router: express.Router = express.Router(); + +router.get( + '/erc1155/:erc1155Id/:tokenId', + assertRequestInput(ReadERC1155Metadata.validation), + ReadERC1155Metadata.controller, +); +router.get('/:metadataId', assertRequestInput(ReadMetadata.validation), ReadMetadata.controller); + +export default router; diff --git a/apps/api/src/app/controllers/participants/get.controller.ts b/apps/api/src/app/controllers/participants/get.controller.ts new file mode 100644 index 000000000..0e649e376 --- /dev/null +++ b/apps/api/src/app/controllers/participants/get.controller.ts @@ -0,0 +1,38 @@ +import { Request, Response } from 'express'; +import { Participant } from '@thxnetwork/api/models/Participant'; +import { query } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import IdentityService from '@thxnetwork/api/services/IdentityService'; +import PoolService from '@thxnetwork/api/services/PoolService'; + +const validation = [query('poolId').optional().isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const poolId = req.query.poolId as string; + const query: { sub: string; poolId?: string } = { sub: req.auth.sub }; + if (poolId) query.poolId = poolId; + + // Get all participants for the authenticated user and optionally filter by poolId + const participants = await Participant.find(query); + + // Run pool specific operations + if (poolId) { + const pool = await PoolService.getById(poolId); + const account = await AccountProxy.findById(req.auth.sub); + if (!account) throw new NotFoundError('Account not found.'); + + // Force connect account address as identity might be available + await IdentityService.forceConnect(pool, account); + + // If no participants were found, create a participant for the authenticated user + if (!participants.length) { + const participant = await Participant.create({ poolId, sub: account.sub }); + participants.push(participant); + } + } + + res.json(participants); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/participants/participants.router.ts b/apps/api/src/app/controllers/participants/participants.router.ts new file mode 100644 index 000000000..d4e914a45 --- /dev/null +++ b/apps/api/src/app/controllers/participants/participants.router.ts @@ -0,0 +1,21 @@ +import { assertRequestInput, guard } from '@thxnetwork/api/middlewares'; +import express from 'express'; +import * as ListParticipants from './get.controller'; +import * as UpdateParticipants from './patch.controller'; + +const router: express.Router = express.Router(); + +router.get( + '/', + guard.check(['point_balances:read']), + assertRequestInput(ListParticipants.validation), + ListParticipants.controller, +); +router.patch( + '/:id', + guard.check(['point_balances:read']), + assertRequestInput(UpdateParticipants.validation), + UpdateParticipants.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/participants/patch.controller.ts b/apps/api/src/app/controllers/participants/patch.controller.ts new file mode 100644 index 000000000..4e06eb598 --- /dev/null +++ b/apps/api/src/app/controllers/participants/patch.controller.ts @@ -0,0 +1,31 @@ +import { Request, Response } from 'express'; +import { Participant } from '@thxnetwork/api/models/Participant'; +import { body, param } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; + +const validation = [ + param('id').isMongoId(), + body('isSubscribed').optional().isBoolean(), + body('email').optional().isEmail(), +]; + +const controller = async (req: Request, res: Response) => { + const participant = await Participant.findById(req.params.id); + if (!participant) throw new NotFoundError('Participant not found.'); + + // If subscribed is true and email we set the participant flag to true and patch the account + if (req.body.isSubscribed && req.body.email) { + const isSubscribed = JSON.parse(req.body.isSubscribed); + + if (isSubscribed) { + await AccountProxy.update(req.auth.sub, { email: String(req.body.email) } as TAccount); + } + + await participant.updateOne({ isSubscribed }); + } + + res.status(204).end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/analytics/analytics.router.ts b/apps/api/src/app/controllers/pools/analytics/analytics.router.ts new file mode 100644 index 000000000..ceb3dda7e --- /dev/null +++ b/apps/api/src/app/controllers/pools/analytics/analytics.router.ts @@ -0,0 +1,18 @@ +import express from 'express'; +import { assertRequestInput, assertPoolAccess, guard } from '@thxnetwork/api/middlewares'; +import RouterMetrics from './metrics/metrics.router'; +import * as ListAnalytics from './list.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get( + '/', + guard.check(['pools:read']), + assertPoolAccess, + assertRequestInput(ListAnalytics.validation), + ListAnalytics.controller, +); + +router.use('/metrics', RouterMetrics); + +export default router; diff --git a/apps/api/src/app/controllers/pools/analytics/list.controller.ts b/apps/api/src/app/controllers/pools/analytics/list.controller.ts new file mode 100644 index 000000000..dc912070f --- /dev/null +++ b/apps/api/src/app/controllers/pools/analytics/list.controller.ts @@ -0,0 +1,17 @@ +import { Request, Response } from 'express'; +import { param, query } from 'express-validator'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import AnalyticsService from '@thxnetwork/api/services/AnalyticsService'; + +const validation = [param('id').isMongoId(), query('startDate').exists(), query('endDate').exists()]; + +const controller = async (req: Request, res: Response) => { + const pool = await PoolService.getById(req.params.id); + const startDate = new Date(String(req.query.startDate)); + const endDate = new Date(String(req.query.endDate)); + const result = await AnalyticsService.getPoolAnalyticsForChart(pool, startDate, endDate); + + res.json(result); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/analytics/metrics/list.controller.ts b/apps/api/src/app/controllers/pools/analytics/metrics/list.controller.ts new file mode 100644 index 000000000..0c8b55f71 --- /dev/null +++ b/apps/api/src/app/controllers/pools/analytics/metrics/list.controller.ts @@ -0,0 +1,20 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import AnalyticsService from '@thxnetwork/api/services/AnalyticsService'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import { Participant } from '@thxnetwork/api/models/Participant'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['Pools'] + const pool = await PoolService.getById(req.params.id); + const metrics = await AnalyticsService.getPoolMetrics(pool); + const participantCount = await Participant.countDocuments({ poolId: pool._id }); + const participantActiveCount = await Participant.countDocuments({ poolId: pool._id, score: { $gt: 0 } }); + const subscriptionCount = await Participant.countDocuments({ poolId: pool._id, isSubscribed: true }); + + res.json({ _id: pool._id, participantCount, participantActiveCount, subscriptionCount, ...metrics }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/analytics/metrics/metrics.router.ts b/apps/api/src/app/controllers/pools/analytics/metrics/metrics.router.ts new file mode 100644 index 000000000..789d44352 --- /dev/null +++ b/apps/api/src/app/controllers/pools/analytics/metrics/metrics.router.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import { assertRequestInput, assertPoolAccess, guard } from '@thxnetwork/api/middlewares'; +import * as ListMetrics from './list.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get( + '/', + guard.check(['pools:read']), + assertPoolAccess, + assertRequestInput(ListMetrics.validation), + ListMetrics.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/pools/collaborators/collaborators.router.ts b/apps/api/src/app/controllers/pools/collaborators/collaborators.router.ts new file mode 100644 index 000000000..bf3469c98 --- /dev/null +++ b/apps/api/src/app/controllers/pools/collaborators/collaborators.router.ts @@ -0,0 +1,30 @@ +import express from 'express'; +import { assertRequestInput, assertPoolAccess, guard } from '@thxnetwork/api/middlewares'; +import * as CreateController from './post.controller'; +import * as UpdateController from './patch.controller'; +import * as RemoveController from './delete.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.post( + '/', + guard.check(['pools:read', 'pools:write']), + assertPoolAccess, + assertRequestInput(CreateController.validation), + CreateController.controller, +); +router.patch( + '/:uuid', + guard.check(['pools:read', 'pools:write']), + assertRequestInput(UpdateController.validation), + UpdateController.controller, +); +router.delete( + '/:uuid', + guard.check(['pools:read', 'pools:write']), + assertPoolAccess, + assertRequestInput(RemoveController.validation), + RemoveController.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/pools/collaborators/delete.controller.ts b/apps/api/src/app/controllers/pools/collaborators/delete.controller.ts new file mode 100644 index 000000000..9689f1a92 --- /dev/null +++ b/apps/api/src/app/controllers/pools/collaborators/delete.controller.ts @@ -0,0 +1,21 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { ForbiddenError, NotFoundError } from '@thxnetwork/api/util/errors'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import { Collaborator } from '@thxnetwork/api/models/Collaborator'; + +const validation = [param('id').isMongoId(), param('uuid').isUUID(4)]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['Pools'] + const pool = await PoolService.getById(req.params.id); + const collaborator = await Collaborator.findOne({ poolId: pool._id, uuid: req.params.uuid }); + if (!collaborator) throw new NotFoundError('Could not find collaborator'); + if (collaborator.sub === pool.sub) throw new ForbiddenError('Can not remove campaign owner'); + + await collaborator.deleteOne(); + + res.status(204).end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/collaborators/patch.controller.ts b/apps/api/src/app/controllers/pools/collaborators/patch.controller.ts new file mode 100644 index 000000000..ba372cac7 --- /dev/null +++ b/apps/api/src/app/controllers/pools/collaborators/patch.controller.ts @@ -0,0 +1,26 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { Collaborator } from '@thxnetwork/api/models/Collaborator'; +import { CollaboratorInviteState } from '@thxnetwork/common/enums'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; + +const validation = [param('id').isMongoId(), param('uuid').isUUID(4)]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['Pools'] + const pool = await PoolService.getById(req.params.id); + const collaborator = await Collaborator.findOne({ poolId: req.params.id, uuid: req.params.uuid }); + if (!collaborator) throw new NotFoundError('Could not find collaboration invite'); + + if (pool.sub !== req.body.sub) { + await collaborator.updateOne({ + sub: req.auth.sub, + state: CollaboratorInviteState.Accepted, + }); + } + + res.end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/collaborators/post.controller.ts b/apps/api/src/app/controllers/pools/collaborators/post.controller.ts new file mode 100644 index 000000000..74fbb57e4 --- /dev/null +++ b/apps/api/src/app/controllers/pools/collaborators/post.controller.ts @@ -0,0 +1,14 @@ +import { Request, Response } from 'express'; +import { param, body } from 'express-validator'; +import PoolService from '@thxnetwork/api/services/PoolService'; + +const validation = [param('id').isMongoId(), body('email').isEmail()]; + +const controller = async (req: Request, res: Response) => { + const pool = await PoolService.getById(req.params.id); + const collaborator = await PoolService.inviteCollaborator(pool, req.body.email); + + res.json(collaborator); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/delete.controller.ts b/apps/api/src/app/controllers/pools/delete.controller.ts new file mode 100644 index 000000000..14b773433 --- /dev/null +++ b/apps/api/src/app/controllers/pools/delete.controller.ts @@ -0,0 +1,18 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { Pool } from '@thxnetwork/api/models'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const pool = await PoolService.getById(req.params.id); + if (!pool) throw new NotFoundError('Could not find pool for this ID'); + + await Pool.deleteOne({ _id: pool._id }); + + res.status(204).end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/erc1155/balance/balance.router.ts b/apps/api/src/app/controllers/pools/erc1155/balance/balance.router.ts new file mode 100644 index 000000000..1e4d828ad --- /dev/null +++ b/apps/api/src/app/controllers/pools/erc1155/balance/balance.router.ts @@ -0,0 +1,9 @@ +import express from 'express'; +import { assertRequestInput, guard } from '@thxnetwork/api/middlewares'; +import * as ReadBalances from './get.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get('/', guard.check(['erc1155:read']), assertRequestInput(ReadBalances.validation), ReadBalances.controller); + +export default router; diff --git a/apps/api/src/app/controllers/pools/erc1155/balance/get.controller.ts b/apps/api/src/app/controllers/pools/erc1155/balance/get.controller.ts new file mode 100644 index 000000000..715636748 --- /dev/null +++ b/apps/api/src/app/controllers/pools/erc1155/balance/get.controller.ts @@ -0,0 +1,23 @@ +import { Request, Response } from 'express'; +import { query } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import ContractService from '@thxnetwork/api/services/ContractService'; +import SafeService from '@thxnetwork/api/services/SafeService'; +import { Pool } from '@thxnetwork/api/models'; + +const validation = [query('contractAddress').isEthereumAddress(), query('tokenId').isInt()]; + +const controller = async (req: Request, res: Response) => { + const pool = await Pool.findById(req.params.id); + if (!pool) throw new NotFoundError('Pool not found'); + + const safe = await SafeService.findOneByPool(pool, pool.chainId); + if (!safe) throw new NotFoundError('Safe not found'); + + const abi = ContractService.getAbiForContractName('THX_ERC1155'); + const contract = ContractService.getContractFromAbi(pool.chainId, abi, req.query.contractAddress as string); + const balance = await contract.methods.balanceOf(safe.address, req.query.tokenId).call(); + + res.json({ balance }); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/erc1155/erc1155.router.ts b/apps/api/src/app/controllers/pools/erc1155/erc1155.router.ts new file mode 100644 index 000000000..3808e042a --- /dev/null +++ b/apps/api/src/app/controllers/pools/erc1155/erc1155.router.ts @@ -0,0 +1,8 @@ +import express from 'express'; +import RouterBalance from './balance/balance.router'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.use('/balance', RouterBalance); + +export default router; diff --git a/apps/api/src/app/controllers/pools/erc20/allowance/allowance.router.ts b/apps/api/src/app/controllers/pools/erc20/allowance/allowance.router.ts new file mode 100644 index 000000000..81d369661 --- /dev/null +++ b/apps/api/src/app/controllers/pools/erc20/allowance/allowance.router.ts @@ -0,0 +1,11 @@ +import express from 'express'; +import { assertRequestInput } from '@thxnetwork/api/middlewares'; +import * as ListAllowances from './get.controller'; +import * as CreateAllowances from './post.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get('/', assertRequestInput(ListAllowances.validation), ListAllowances.controller); +router.post('/', assertRequestInput(CreateAllowances.validation), CreateAllowances.controller); + +export default router; diff --git a/apps/api/src/app/controllers/pools/erc20/allowance/get.controller.ts b/apps/api/src/app/controllers/pools/erc20/allowance/get.controller.ts new file mode 100644 index 000000000..08665e565 --- /dev/null +++ b/apps/api/src/app/controllers/pools/erc20/allowance/get.controller.ts @@ -0,0 +1,31 @@ +import { Request, Response } from 'express'; +import { param, query } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import ContractService from '@thxnetwork/api/services/ContractService'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import SafeService from '@thxnetwork/api/services/SafeService'; + +const validation = [ + param('id').isMongoId(), + query('tokenAddress').isEthereumAddress(), + query('spender').isEthereumAddress(), +]; + +const controller = async (req: Request, res: Response) => { + const poolId = req.params.id as string; + const pool = await PoolService.getById(poolId); + if (!pool) throw new NotFoundError('Pool not found'); + + const safe = await SafeService.findOneByPool(pool); + if (!safe) throw new NotFoundError('Wallet not found'); + + const contract = ContractService.getContractFromName( + safe.chainId, + 'LimitedSupplyToken', + req.query.tokenAddress as string, + ); + const allowanceInWei = await contract.methods.allowance(safe.address, req.query.spender).call(); + + res.json({ allowanceInWei: String(allowanceInWei) }); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/erc20/allowance/post.controller.ts b/apps/api/src/app/controllers/pools/erc20/allowance/post.controller.ts new file mode 100644 index 000000000..688e50c69 --- /dev/null +++ b/apps/api/src/app/controllers/pools/erc20/allowance/post.controller.ts @@ -0,0 +1,36 @@ +import { Request, Response } from 'express'; +import { body, param } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { getAbiForContractName } from '@thxnetwork/api/services/ContractService'; +import TransactionService from '@thxnetwork/api/services/TransactionService'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import SafeService from '@thxnetwork/api/services/SafeService'; + +const validation = [ + param('id').isMongoId(), + body('tokenAddress').isEthereumAddress(), + body('spender').isEthereumAddress(), + body('amountInWei').isString(), +]; + +const controller = async (req: Request, res: Response) => { + const poolId = req.params.id as string; + const pool = await PoolService.getById(poolId); + if (!pool) throw new NotFoundError('Pool not found'); + + const safe = await SafeService.findOneByPool(pool); + if (!safe) throw new NotFoundError('Wallet not found'); + + const { web3 } = getProvider(safe.chainId); + + const abi = getAbiForContractName('LimitedSupplyToken'); + const contract = new web3.eth.Contract(abi, req.body.tokenAddress); + const fn = contract.methods.approve(req.body.spender, req.body.amountInWei); + + // Propose tx data to relayer and return safeTxHash to track status + const tx = await TransactionService.sendSafeAsync(safe, contract.options.address, fn); + + res.status(201).json([tx]); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/erc20/balance/balance.router.ts b/apps/api/src/app/controllers/pools/erc20/balance/balance.router.ts new file mode 100644 index 000000000..ceb7452d8 --- /dev/null +++ b/apps/api/src/app/controllers/pools/erc20/balance/balance.router.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import { assertRequestInput, assertPoolAccess, guard } from '@thxnetwork/api/middlewares'; +import * as ReadBalances from './get.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get( + '/', + guard.check(['pools:read', 'pools:write']), + assertPoolAccess, + assertRequestInput(ReadBalances.validation), + ReadBalances.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/pools/erc20/balance/get.controller.ts b/apps/api/src/app/controllers/pools/erc20/balance/get.controller.ts new file mode 100644 index 000000000..eaf50c16e --- /dev/null +++ b/apps/api/src/app/controllers/pools/erc20/balance/get.controller.ts @@ -0,0 +1,27 @@ +import { Request, Response } from 'express'; +import { param, query } from 'express-validator'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import SafeService from '@thxnetwork/api/services/SafeService'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import ContractService from '@thxnetwork/api/services/ContractService'; + +const validation = [param('id').isMongoId(), query('tokenAddress').isEthereumAddress()]; + +const controller = async (req: Request, res: Response) => { + const pool = await PoolService.getById(req.params.id); + if (!pool) throw new NotFoundError('Campaign not found.'); + + const safe = await SafeService.findOneByPool(pool, pool.chainId); + if (!safe) throw new NotFoundError('Campaign Safe not found.'); + + const contract = ContractService.getContractFromAbi( + safe.chainId, + ContractService.getAbiForContractName('LimitedSupplyToken'), + req.query.tokenAddress as string, + ); + const balanceInWei = await contract.methods.balanceOf(safe.address).call(); + + res.json({ balanceInWei: String(balanceInWei) }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/erc20/erc20.router.ts b/apps/api/src/app/controllers/pools/erc20/erc20.router.ts new file mode 100644 index 000000000..6f3d56ca9 --- /dev/null +++ b/apps/api/src/app/controllers/pools/erc20/erc20.router.ts @@ -0,0 +1,10 @@ +import express from 'express'; +import RouterBalance from './balance/balance.router'; +import RouterAllowance from './allowance/allowance.router'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.use('/balance', RouterBalance); +router.use('/allowance', RouterAllowance); + +export default router; diff --git a/apps/api/src/app/controllers/pools/events/events.router.ts b/apps/api/src/app/controllers/pools/events/events.router.ts new file mode 100644 index 000000000..da785004e --- /dev/null +++ b/apps/api/src/app/controllers/pools/events/events.router.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import { assertRequestInput, assertPoolAccess, guard } from '@thxnetwork/api/middlewares'; +import * as ListEvents from './list.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get( + '/', + guard.check(['pools:read']), + assertPoolAccess, + assertRequestInput(ListEvents.validation), + ListEvents.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/pools/events/list.controller.ts b/apps/api/src/app/controllers/pools/events/list.controller.ts new file mode 100644 index 000000000..4077fd4ca --- /dev/null +++ b/apps/api/src/app/controllers/pools/events/list.controller.ts @@ -0,0 +1,29 @@ +import { Request, Response } from 'express'; +import { query } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { Event, EventDocument } from '@thxnetwork/api/models/Event'; +import { paginatedResults } from '@thxnetwork/api/util/pagination'; +import { Identity } from '@thxnetwork/api/models/Identity'; +import { Pool } from '@thxnetwork/api/models'; + +const validation = [query('page').isInt(), query('limit').isInt()]; + +const controller = async (req: Request, res: Response) => { + const pool = await Pool.findById(req.header('X-PoolId')); + if (!pool) throw new NotFoundError('Could not find pool for token'); + + const result = await paginatedResults(Event, Number(req.query.page), Number(req.query.limit), { + poolId: pool._id, + }); + + result.results = await Promise.all( + result.results.map(async (event: EventDocument) => { + const identity = await Identity.findById(event.identityId); + return { ...event.toJSON(), identity }; + }), + ); + + res.json(result); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/get.controller.ts b/apps/api/src/app/controllers/pools/get.controller.ts new file mode 100644 index 000000000..ab7b97c81 --- /dev/null +++ b/apps/api/src/app/controllers/pools/get.controller.ts @@ -0,0 +1,65 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { Participant, Widget, Wallet, Event, Identity } from '@thxnetwork/api/models'; +import { safeVersion } from '@thxnetwork/api/services/ContractService'; +import { logger } from '@thxnetwork/api/util/logger'; +import { ethers } from 'ethers'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import BrandService from '@thxnetwork/api/services/BrandService'; +import SafeService from '@thxnetwork/api/services/SafeService'; +import PaymentService from '@thxnetwork/api/services/PaymentService'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const pool = await PoolService.getById(req.params.id); + let safe = await SafeService.findOneByPool(pool, pool.chainId); + + // Deploy a Safe if none is found + if (!safe) { + safe = await SafeService.create({ + chainId: pool.chainId, + sub: pool.sub, + safeVersion, + poolId: req.params.id, + }); + logger.info(`[${req.params.id}] Deployed Campaign Safe ${safe.address}`); + } + + // Create a galachain private key if none exists + if (!pool.settings.galachainPrivateKey) { + const privateKey = ethers.Wallet.createRandom().privateKey; + await pool.updateOne({ 'settings.galachainPrivateKey': privateKey }); + } + + // Fetch all other campaign entities + const [widget, brand, wallets, collaborators, owner, events, identities, subscriberCount, balance] = + await Promise.all([ + Widget.findOne({ poolId: req.params.id }), + BrandService.get(req.params.id), + Wallet.find({ poolId: req.params.id }), + PoolService.findCollaborators(pool), + PoolService.findOwner(pool), + Event.find({ poolId: pool._id }).distinct('name'), // Seperate list (many) + Identity.find({ poolId: pool._id }), // Seperate list (many) + Participant.countDocuments({ poolId: req.params.id, isSubscribed: true }), + PaymentService.balanceOf(safe), + ]); + + res.json({ + ...pool.toJSON(), + balance, + address: pool.safeAddress, + safe, + identities, + events, + wallets, + widget, + brand, + subscriberCount, + owner, + collaborators, + }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/guilds/delete.controller.ts b/apps/api/src/app/controllers/pools/guilds/delete.controller.ts new file mode 100644 index 000000000..10f8e185c --- /dev/null +++ b/apps/api/src/app/controllers/pools/guilds/delete.controller.ts @@ -0,0 +1,12 @@ +import { param } from 'express-validator'; +import { Request, Response } from 'express'; +import { DiscordGuild } from '@thxnetwork/api/models'; + +const validation = [param('id').isMongoId(), param('guildId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + await DiscordGuild.findByIdAndDelete(req.params.guildId); + res.status(204).end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/guilds/guilds.router.ts b/apps/api/src/app/controllers/pools/guilds/guilds.router.ts new file mode 100644 index 000000000..8563b01d2 --- /dev/null +++ b/apps/api/src/app/controllers/pools/guilds/guilds.router.ts @@ -0,0 +1,39 @@ +import express from 'express'; +import { assertRequestInput, assertPoolAccess, guard } from '@thxnetwork/api/middlewares'; +import * as CreateController from './post.controller'; +import * as UpdateController from './patch.controller'; +import * as RemoveController from './delete.controller'; +import * as ListController from './list.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get( + '/', + guard.check(['pools:read']), + assertPoolAccess, + assertRequestInput(ListController.validation), + ListController.controller, +); +router.post( + '/', + guard.check(['pools:read', 'pools:write']), + assertPoolAccess, + assertRequestInput(CreateController.validation), + CreateController.controller, +); +router.patch( + '/:guildId', + guard.check(['pools:read', 'pools:write']), + assertPoolAccess, + assertRequestInput(UpdateController.validation), + UpdateController.controller, +); +router.delete( + '/:guildId', + guard.check(['pools:read', 'pools:write']), + assertPoolAccess, + assertRequestInput(RemoveController.validation), + RemoveController.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/pools/guilds/list.controller.ts b/apps/api/src/app/controllers/pools/guilds/list.controller.ts new file mode 100644 index 000000000..3beb0e2b9 --- /dev/null +++ b/apps/api/src/app/controllers/pools/guilds/list.controller.ts @@ -0,0 +1,13 @@ +import PoolService from '@thxnetwork/api/services/PoolService'; +import { Request, Response } from 'express'; +import { param } from 'express-validator'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const pool = await PoolService.getById(req.params.id); + const guilds = await PoolService.findGuilds(pool); + res.json(guilds); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/guilds/patch.controller.ts b/apps/api/src/app/controllers/pools/guilds/patch.controller.ts new file mode 100644 index 000000000..26f9f5a50 --- /dev/null +++ b/apps/api/src/app/controllers/pools/guilds/patch.controller.ts @@ -0,0 +1,21 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { DiscordGuild } from '@thxnetwork/api/models'; +import DiscordDataProxy from '@thxnetwork/api/proxies/DiscordDataProxy'; +import * as CreateController from './post.controller'; + +const validation = [param('guildId').optional().isMongoId(), ...CreateController.validation]; + +const controller = async (req: Request, res: Response) => { + const { secret, adminRoleId, channelId } = req.body; + const guild = await DiscordGuild.findByIdAndUpdate( + req.params.guildId, + { secret, channelId, adminRoleId, poolId: req.params.id }, + { new: true }, + ); + const result = await DiscordDataProxy.getGuild({ ...guild.toJSON(), isConnected: true }); + + res.json(result); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/guilds/post.controller.ts b/apps/api/src/app/controllers/pools/guilds/post.controller.ts new file mode 100644 index 000000000..d3137a6db --- /dev/null +++ b/apps/api/src/app/controllers/pools/guilds/post.controller.ts @@ -0,0 +1,27 @@ +import { DiscordGuild } from '@thxnetwork/api/models'; +import DiscordDataProxy from '@thxnetwork/api/proxies/DiscordDataProxy'; +import { Request, Response } from 'express'; +import { body, param } from 'express-validator'; + +const validation = [ + param('id').isMongoId(), + body('settings.channelId').optional().isString(), + body('settings.adminRoleId').optional().isString(), +]; + +const controller = async (req: Request, res: Response) => { + const { guildId, name, adminRoleId, channelId } = req.body; + const guild = await DiscordGuild.create({ + sub: req.auth.sub, + guildId, + name, + channelId, + adminRoleId, + poolId: req.params.id, + }); + const result = await DiscordDataProxy.getGuild({ ...guild.toJSON(), isConnected: true }); + + res.json(result); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/identities/delete.controller.ts b/apps/api/src/app/controllers/pools/identities/delete.controller.ts new file mode 100644 index 000000000..43b11cad6 --- /dev/null +++ b/apps/api/src/app/controllers/pools/identities/delete.controller.ts @@ -0,0 +1,17 @@ +import { Request, Response } from 'express'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { Identity } from '@thxnetwork/api/models/Identity'; +import { param } from 'express-validator'; +import PoolService from '@thxnetwork/api/services/PoolService'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const pool = await PoolService.getById(req.params.id); + if (!pool) throw new NotFoundError('Could not find pool for client'); + + await Identity.findByIdAndDelete(req.params.identityId); + res.status(204).end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/identities/get.controller.ts b/apps/api/src/app/controllers/pools/identities/get.controller.ts new file mode 100644 index 000000000..85e3ec6e1 --- /dev/null +++ b/apps/api/src/app/controllers/pools/identities/get.controller.ts @@ -0,0 +1,19 @@ +import { Request, Response } from 'express'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { param, query } from 'express-validator'; +import PoolService from '@thxnetwork/api/services/PoolService'; + +const validation = [param('id').isMongoId(), query('page').isInt(), query('limit').isInt()]; + +const controller = async (req: Request, res: Response) => { + const pool = await PoolService.getById(req.params.id); + if (!pool) throw new NotFoundError('Could not find pool for client'); + + const page = Number(req.query.page); + const limit = Number(req.query.limit); + const identities = await PoolService.findIdentities(pool, page, limit); + + res.json(identities); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/identities/identities.router.ts b/apps/api/src/app/controllers/pools/identities/identities.router.ts new file mode 100644 index 000000000..0603f6a22 --- /dev/null +++ b/apps/api/src/app/controllers/pools/identities/identities.router.ts @@ -0,0 +1,23 @@ +import express from 'express'; +import * as CreateController from './post.controller'; +import * as ListController from './get.controller'; +import * as DeleteController from './delete.controller'; +import { assertRequestInput, guard } from '@thxnetwork/api/middlewares'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get('/', guard.check(['pools:read']), assertRequestInput(ListController.validation), ListController.controller); +router.post( + '/', + guard.check(['pools:read', 'pools:write']), + assertRequestInput(CreateController.validation), + CreateController.controller, +); +router.delete( + '/:identityId', + guard.check(['pools:read', 'pools:write']), + assertRequestInput(DeleteController.validation), + DeleteController.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/pools/identities/post.controller.ts b/apps/api/src/app/controllers/pools/identities/post.controller.ts new file mode 100644 index 000000000..5f85c0308 --- /dev/null +++ b/apps/api/src/app/controllers/pools/identities/post.controller.ts @@ -0,0 +1,20 @@ +import { Request, Response } from 'express'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { Identity } from '@thxnetwork/api/models/Identity'; +import { uuidV1 } from '@thxnetwork/api/util/uuid'; +import { param } from 'express-validator'; +import PoolService from '@thxnetwork/api/services/PoolService'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const pool = await PoolService.getById(req.params.id); + if (!pool) throw new NotFoundError('Could not find pool for client'); + + const uuid = uuidV1(); + const id = await Identity.create({ poolId: pool._id, uuid }); + + res.json(id.uuid); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/integrations/integrations.router.ts b/apps/api/src/app/controllers/pools/integrations/integrations.router.ts new file mode 100644 index 000000000..886f3fc84 --- /dev/null +++ b/apps/api/src/app/controllers/pools/integrations/integrations.router.ts @@ -0,0 +1,8 @@ +import express from 'express'; +import RouterTwitter from './twitter/twitter.router'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.use('/twitter', RouterTwitter); + +export default router; diff --git a/apps/api/src/app/controllers/pools/integrations/twitter/queries/delete.controller.ts b/apps/api/src/app/controllers/pools/integrations/twitter/queries/delete.controller.ts new file mode 100644 index 000000000..f1a5284c3 --- /dev/null +++ b/apps/api/src/app/controllers/pools/integrations/twitter/queries/delete.controller.ts @@ -0,0 +1,17 @@ +import { param } from 'express-validator'; +import { Request, Response } from 'express'; +import { TwitterQuery } from '@thxnetwork/api/models'; +import { ForbiddenError } from '@thxnetwork/api/util/errors'; + +const validation = [param('id').isMongoId(), param('queryId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const query = await TwitterQuery.findById(req.params.queryId); + if (query.poolId !== req.params.id) throw new ForbiddenError('Not your quest.'); + + await TwitterQuery.findByIdAndDelete(req.params.queryId); + + res.status(204).end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/integrations/twitter/queries/list.controller.ts b/apps/api/src/app/controllers/pools/integrations/twitter/queries/list.controller.ts new file mode 100644 index 000000000..368485254 --- /dev/null +++ b/apps/api/src/app/controllers/pools/integrations/twitter/queries/list.controller.ts @@ -0,0 +1,13 @@ +import { Request, Response } from 'express'; +import { TwitterQuery } from '@thxnetwork/api/models'; +import { param } from 'express-validator'; +import TwitterQueryService from '@thxnetwork/api/services/TwitterQueryService'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const queries = await TwitterQueryService.list({ poolId: req.params.id }); + res.json(queries); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/integrations/twitter/queries/patch.controller.ts b/apps/api/src/app/controllers/pools/integrations/twitter/queries/patch.controller.ts new file mode 100644 index 000000000..89addf2fe --- /dev/null +++ b/apps/api/src/app/controllers/pools/integrations/twitter/queries/patch.controller.ts @@ -0,0 +1,22 @@ +import { body, param } from 'express-validator'; +import { Request, Response } from 'express'; +import { TwitterQuery } from '@thxnetwork/api/models'; +import { TwitterQuery as TwitterQueryParser } from '@thxnetwork/common/twitter'; + +const validation = [ + param('id').isMongoId(), + param('queryId').isMongoId(), + body('operators').customSanitizer((ops) => TwitterQueryParser.parse(ops)), +]; + +const controller = async (req: Request, res: Response) => { + const query = TwitterQueryParser.create(req.body.operators); + const twitterQuery = await TwitterQuery.findByIdAndUpdate(req.params.queryId, { + operators: req.body.operators, + query, + }); + + res.json(twitterQuery); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/integrations/twitter/queries/post.controller.ts b/apps/api/src/app/controllers/pools/integrations/twitter/queries/post.controller.ts new file mode 100644 index 000000000..23dae01e8 --- /dev/null +++ b/apps/api/src/app/controllers/pools/integrations/twitter/queries/post.controller.ts @@ -0,0 +1,31 @@ +import { body, param } from 'express-validator'; +import { Request, Response } from 'express'; +import { TwitterQuery } from '@thxnetwork/api/models'; +import { TwitterQuery as TwitterQueryParser } from '@thxnetwork/common/twitter'; +import { BadRequestError } from '@thxnetwork/api/util/errors'; +import TwitterQueryService from '@thxnetwork/api/services/TwitterQueryService'; + +const validation = [param('id').isMongoId(), body('operators').customSanitizer((ops) => TwitterQueryParser.parse(ops))]; + +const controller = async (req: Request, res: Response) => { + const query = TwitterQueryParser.create(req.body.operators); + + // 512 is the max length for X API queries within the Basic plan + if (query.length > 512) { + throw new BadRequestError('Your query is too long! Please remove some fields.'); + } + + const twitterQuery = await TwitterQuery.create({ + poolId: req.params.id, + operators: req.body.operators, + defaults: req.body.defaults, + query, + }); + + // Search initial posts and create quests + await TwitterQueryService.run([twitterQuery]); + + res.status(201).json(twitterQuery); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/integrations/twitter/twitter.router.ts b/apps/api/src/app/controllers/pools/integrations/twitter/twitter.router.ts new file mode 100644 index 000000000..ca75ffc7d --- /dev/null +++ b/apps/api/src/app/controllers/pools/integrations/twitter/twitter.router.ts @@ -0,0 +1,39 @@ +import express from 'express'; +import * as CreateController from './queries/post.controller'; +import * as UpdateController from './queries/patch.controller'; +import * as DeleteController from './queries/delete.controller'; +import * as ListController from './queries/list.controller'; +import { assertPoolAccess, assertRequestInput, guard } from '@thxnetwork/api/middlewares'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get( + '/queries', + guard.check(['pools:read']), + assertPoolAccess, + assertRequestInput(ListController.validation), + ListController.controller, +); +router.post( + '/queries', + guard.check(['pools:read', 'pools:write']), + assertPoolAccess, + assertRequestInput(CreateController.validation), + CreateController.controller, +); +router.patch( + '/queries/queryId', + guard.check(['pools:read', 'pools:write']), + assertPoolAccess, + assertRequestInput(UpdateController.validation), + UpdateController.controller, +); +router.delete( + '/queries/:queryId', + guard.check(['pools:read', 'pools:write']), + assertPoolAccess, + assertRequestInput(DeleteController.validation), + DeleteController.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/pools/invoices/invoices.router.ts b/apps/api/src/app/controllers/pools/invoices/invoices.router.ts new file mode 100644 index 000000000..0f67df758 --- /dev/null +++ b/apps/api/src/app/controllers/pools/invoices/invoices.router.ts @@ -0,0 +1,9 @@ +import express from 'express'; +import * as ListInvoices from './list.controller'; +import { assertRequestInput, guard } from '@thxnetwork/api/middlewares'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get('/', guard.check(['pools:read']), assertRequestInput(ListInvoices.validation), ListInvoices.controller); + +export default router; diff --git a/apps/api/src/app/controllers/pools/invoices/list.controller.ts b/apps/api/src/app/controllers/pools/invoices/list.controller.ts new file mode 100644 index 000000000..57e6a68b8 --- /dev/null +++ b/apps/api/src/app/controllers/pools/invoices/list.controller.ts @@ -0,0 +1,15 @@ +import { Request, Response } from 'express'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import { Invoice } from '@thxnetwork/api/models'; + +const validation = []; + +const controller = async (req: Request, res: Response) => { + const pool = await PoolService.getById(req.params.id); + if (!pool) throw new Error('Pool not found'); + + const invoices = await Invoice.find({ poolId: pool._id }); + res.json(invoices); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/list.controller.ts b/apps/api/src/app/controllers/pools/list.controller.ts new file mode 100644 index 000000000..de5486a88 --- /dev/null +++ b/apps/api/src/app/controllers/pools/list.controller.ts @@ -0,0 +1,11 @@ +import { Request, Response } from 'express'; +import PoolService from '@thxnetwork/api/services/PoolService'; + +const validation = []; + +const controller = async (req: Request, res: Response) => { + const pools = await PoolService.getAllBySub(req.auth.sub); + res.json(pools); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/participants/list.controller.ts b/apps/api/src/app/controllers/pools/participants/list.controller.ts new file mode 100644 index 000000000..3190e4a54 --- /dev/null +++ b/apps/api/src/app/controllers/pools/participants/list.controller.ts @@ -0,0 +1,21 @@ +import { Request, Response } from 'express'; +import { param, query } from 'express-validator'; +import PoolService from '@thxnetwork/api/services/PoolService'; + +const validation = [ + param('id').isMongoId(), + query('page').isInt(), + query('limit').isInt(), + query('page').optional().isString(), + query('query').optional().isString().isLength({ min: 3 }), +]; + +const controller = async (req: Request, res: Response) => { + const pool = await PoolService.getById(req.params.id); + const { page, limit, query } = req.query; + const participants = await PoolService.findParticipants(pool, Number(page), Number(limit), query as string); + + res.json(participants); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/participants/participants.router.ts b/apps/api/src/app/controllers/pools/participants/participants.router.ts new file mode 100644 index 000000000..69b72b83b --- /dev/null +++ b/apps/api/src/app/controllers/pools/participants/participants.router.ts @@ -0,0 +1,23 @@ +import express from 'express'; +import { assertRequestInput, assertPoolAccess, guard } from '@thxnetwork/api/middlewares'; +import * as ListParticipants from './list.controller'; +import * as UpdateParticipants from './patch.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get( + '/', + guard.check(['pools:read']), + assertPoolAccess, + assertRequestInput(ListParticipants.validation), + ListParticipants.controller, +); +router.patch( + '/:participantId', + guard.check(['pools:read', 'pools:write']), + assertPoolAccess, + assertRequestInput(UpdateParticipants.validation), + UpdateParticipants.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/pools/participants/patch.controller.ts b/apps/api/src/app/controllers/pools/participants/patch.controller.ts new file mode 100644 index 000000000..9bc97b22d --- /dev/null +++ b/apps/api/src/app/controllers/pools/participants/patch.controller.ts @@ -0,0 +1,29 @@ +import { Participant } from '@thxnetwork/api/models/Participant'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { Request, Response } from 'express'; +import { body, param } from 'express-validator'; + +const validation = [ + param('id').isMongoId(), + param('participantId').isMongoId(), + body('pointBalance').optional().isInt({ min: 0 }), +]; + +const controller = async (req: Request, res: Response) => { + const participant = await Participant.findById(req.params.participantId); + if (!participant) throw new NotFoundError('Participant not found.'); + + let pointBalance; + if (typeof req.body.pointBalance !== 'undefined') { + const { balance } = await Participant.findOneAndUpdate( + { poolId: participant.poolId, sub: participant.sub }, + { balance: Number(req.body.pointBalance) }, + { new: true, upsert: true }, + ); + pointBalance = balance; + } + + res.json({ ...participant.toJSON(), pointBalance }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/patch.controller.ts b/apps/api/src/app/controllers/pools/patch.controller.ts new file mode 100644 index 000000000..918c1510d --- /dev/null +++ b/apps/api/src/app/controllers/pools/patch.controller.ts @@ -0,0 +1,54 @@ +import { Request, Response } from 'express'; +import { body, param } from 'express-validator'; +import { BadRequestError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { Pool } from '@thxnetwork/api/models'; +import { JobType, agenda } from '@thxnetwork/api/util/agenda'; +import PoolService from '@thxnetwork/api/services/PoolService'; + +const validation = [ + param('id').exists(), + body('settings.title').optional().isString().trim().escape().isLength({ max: 50 }), + body('settings.slug').optional().isString().trim().escape().isLength({ min: 3, max: 25 }), + body('settings.description').optional().isString().trim().escape().isLength({ max: 255 }), + body('settings.startDate').optional({ nullable: true }).isString(), + body('settings.endDate').optional({ nullable: true }).isString(), + body('settings.discordWebhookUrl').optional({ checkFalsy: true }).isURL(), + body('settings.isArchived').optional().isBoolean(), + body('settings.isPublished').optional().isBoolean(), + body('settings.isWeeklyDigestEnabled').optional().isBoolean(), + body('settings.isTwitterSyncEnabled').optional().isBoolean(), + body('settings.defaults.conditionalRewards.title').optional().isString(), + body('settings.defaults.conditionalRewards.description').optional().isString(), + body('settings.defaults.conditionalRewards.amount').optional().isInt(), + body('settings.defaults.conditionalRewards.hashtag').optional().isString(), + body('settings.defaults.conditionalRewards.isPublished').optional().isBoolean(), + body('settings.authenticationMethods').optional().isArray(), +]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['Pools'] + const pool = await PoolService.getById(req.params.id); + if (!pool) throw new NotFoundError('Could not find the Asset Pool for this id'); + + const { settings } = req.body; + const isSlugUsed = !!(await Pool.exists({ + '_id': { $ne: pool._id }, + 'settings.slug': settings.slug, + })); + if (settings && settings.slug && isSlugUsed) { + throw new BadRequestError('This slug is in use already.'); + } + + const result = await Pool.findByIdAndUpdate( + pool._id, + { settings: Object.assign(pool.settings, req.body.settings) }, + { new: true }, + ); + + if (settings.isPublished && settings.isPublished !== pool.settings.isPublished) { + await agenda.now(JobType.UpdateCampaignRanks); + } + + return res.json(result); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/payments/payments.router.ts b/apps/api/src/app/controllers/pools/payments/payments.router.ts new file mode 100644 index 000000000..143b2c945 --- /dev/null +++ b/apps/api/src/app/controllers/pools/payments/payments.router.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import { assertRequestInput, assertPoolAccess, guard } from '@thxnetwork/api/middlewares'; +import * as CreatePayments from './post.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.post( + '/', + guard.check(['pools:read', 'pools:write']), + assertPoolAccess, + assertRequestInput(CreatePayments.validation), + CreatePayments.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/pools/payments/post.controller.ts b/apps/api/src/app/controllers/pools/payments/post.controller.ts new file mode 100644 index 000000000..090c56ba2 --- /dev/null +++ b/apps/api/src/app/controllers/pools/payments/post.controller.ts @@ -0,0 +1,52 @@ +import { Request, Response } from 'express'; +import { InsufficientAllowanceError, InsufficientBalanceError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { body, param } from 'express-validator'; +import { BigNumber } from 'ethers'; +import { contractArtifacts, contractNetworks } from '@thxnetwork/api/contracts'; +import { getProvider } from '@thxnetwork/api/util/network'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import SafeService from '@thxnetwork/api/services/SafeService'; +import PaymentService from '@thxnetwork/api/services/PaymentService'; + +const validation = [param('id').isMongoId(), body('amountInWei').exists(), body('planType').isInt()]; + +// TODO +// 1. Customer approves USDC for Campaign Safe for x allowance +// 2. Campaign Safe calls multiSend for multiple transactions +// 2.1 transfer 30% of USDC allowance to Company Safe +// 2.2 joinPool 70% of USDC allowance to BalancerVault +// 2.3 stake 100% of BPT +// 2.4 transfer 75% of BPTGauge to RewardDistributor +// 3. hold 25% of BPTGauge for Quest Incentives (or autocompounding) + +const controller = async (req: Request, res: Response) => { + const pool = await PoolService.getById(req.params.id); + if (!pool) throw new NotFoundError('Could not find campaign'); + + const safe = await SafeService.findOneByPool(pool, pool.chainId); + if (!safe) throw new NotFoundError('Could not find campaign Safe'); + + const amountInWei = BigNumber.from(req.body.amountInWei); + const addresses = contractNetworks[safe.chainId]; + + // Assert USDC balance for Safe to ensure throughput + const { web3 } = getProvider(safe.chainId); + const usdc = new web3.eth.Contract(contractArtifacts['USDC'].abi, addresses.USDC); + const balance = await usdc.methods.balanceOf(safe.address).call(); + if (BigNumber.from(balance).lt(amountInWei)) { + throw new InsufficientBalanceError(); + } + + // Assert allowance for Safe to PaymentSplitter + const allowance = await usdc.methods.allowance(safe.address, addresses.THXPaymentSplitter).call(); + if (BigNumber.from(allowance).lt(amountInWei)) { + throw new InsufficientAllowanceError(); + } + + // Execute approve from Safe to PaymentSplitter + await PaymentService.deposit(safe, req.auth.sub, amountInWei); + + res.status(201).end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/pools.router.ts b/apps/api/src/app/controllers/pools/pools.router.ts new file mode 100644 index 000000000..6774c92fe --- /dev/null +++ b/apps/api/src/app/controllers/pools/pools.router.ts @@ -0,0 +1,77 @@ +import express from 'express'; +import { assertRequestInput, assertPoolAccess, assertPayment, guard } from '@thxnetwork/api/middlewares'; + +import * as ListController from './list.controller'; +import * as ReadController from './get.controller'; +import * as CreateController from './post.controller'; +import * as UpdateController from './patch.controller'; +import * as DeleteController from './delete.controller'; + +import RouterCollaborators from './collaborators/collaborators.router'; +import RouterParticipants from './participants/participants.router'; +import RouterAnalytics from './analytics/analytics.router'; +import RouterEvents from './events/events.router'; +import RouterQuests from './quests/quests.router'; +import RouterRewards from './rewards/rewards.router'; +import RouterGuilds from './guilds/guilds.router'; +import RouterPayments from './payments/payments.router'; +import RouterWallets from './wallets/wallets.router'; +import RouterERC20 from './erc20/erc20.router'; +import RouterER1155 from './erc1155/erc1155.router'; +import RouterIdentities from './identities/identities.router'; +import RouterInvoices from './invoices/invoices.router'; +import RouterIntegrations from './integrations/integrations.router'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get('/', guard.check(['pools:read']), assertRequestInput(ListController.validation), ListController.controller); +router.post( + '/', + guard.check(['pools:read', 'pools:write']), + assertRequestInput(CreateController.validation), + CreateController.controller, +); + +// This route is also asserted for payment but not for access +router.use('/:id/collaborators', assertPayment, RouterCollaborators); + +// Everything below is asserted for campaign/pool access +router.use('/:id', assertPoolAccess); +router.get( + '/:id', + guard.check(['pools:read']), + assertRequestInput(ReadController.validation), + ReadController.controller, +); +router.patch( + '/:id', + guard.check(['pools:read', 'pools:write']), + assertRequestInput(UpdateController.validation), + UpdateController.controller, +); +router.delete( + '/:id', + guard.check(['pools:write']), + assertRequestInput(DeleteController.validation), + DeleteController.controller, +); + +// Payment related routes that require access event if payment assertion fails +router.use('/:id/erc20', RouterERC20); // Needed for payment processing +router.use('/:id/payments', RouterPayments); +router.use('/:id/invoices', RouterInvoices); + +// Everything below is asserted for payment +router.use('/:id', assertPayment); +router.use('/:id/analytics', RouterAnalytics); +router.use('/:id/quests', RouterQuests); +router.use('/:id/rewards', RouterRewards); +router.use('/:id/participants', RouterParticipants); +router.use('/:id/wallets', RouterWallets); +router.use('/:id/events', RouterEvents); +router.use('/:id/guilds', RouterGuilds); +router.use('/:id/erc1155', RouterER1155); +router.use('/:id/identities', RouterIdentities); +router.use('/:id/integrations', RouterIntegrations); + +export default router; diff --git a/apps/api/src/app/controllers/pools/pools.test.ts b/apps/api/src/app/controllers/pools/pools.test.ts new file mode 100644 index 000000000..ae6bbd964 --- /dev/null +++ b/apps/api/src/app/controllers/pools/pools.test.ts @@ -0,0 +1,88 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { ChainId } from '@thxnetwork/common/enums'; +import { isAddress } from 'web3-utils'; +import { timeTravel } from '@thxnetwork/api/util/jest/network'; +import { dashboardAccessToken } from '@thxnetwork/api/util/jest/constants'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { poll } from '@thxnetwork/api/util/polling'; + +const user = request.agent(app); + +describe('Default Pool', () => { + let poolId: string, safe: { address: string }; + + beforeAll(beforeAllCallback); + afterAll(afterAllCallback); + + describe('POST /pools', () => { + it('HTTP 201 (success)', async () => { + const { body, status } = await user + .post('/v1/pools') + .set('Authorization', dashboardAccessToken) + .send({ title: 'My Pool', chainId: ChainId.Hardhat }); + expect(status).toBe(201); + poolId = body._id; + expect(body.safe.address).toBeDefined(); + safe = body.safe; + expect(body.settings.title).toBe('My Pool'); + }); + + it('HTTP 200 (multisig deployed)', async () => { + // Wait for campaign safe to be deployed + const { web3 } = getProvider(ChainId.Hardhat); + await poll( + () => web3.eth.getCode(safe.address), + (data: string) => data === '0x', + 1000, + ); + + await user + .get(`/v1/pools/${poolId}`) + .set({ 'X-PoolId': poolId, 'Authorization': dashboardAccessToken }) + .expect((res: request.Response) => { + expect(isAddress(res.body.safeAddress)).toBe(true); + }) + .expect(200); + }); + }); + + // describe('GET /pools/:id (post trial)', () => { + // it('HTTP 403 after 2 weeks', async () => { + // // Skip 2 weeks + // await timeTravel(60 * 60 * 24 * 14); + + // await user + // .get('/v1/pools/' + poolId) + // .set({ Authorization: dashboardAccessToken }) + // .expect(403); + // }); + // }); + + describe('PATCH /pools/:id', () => { + it('HTTP 200', (done) => { + user.patch('/v1/pools/' + poolId) + .set({ 'X-PoolId': poolId, 'Authorization': dashboardAccessToken }) + .send({ + settings: { + title: 'My Pool 2', + isArchived: true, + }, + }) + .expect(({ body }: request.Response) => { + expect(body.settings.title).toBe('My Pool 2'); + expect(body.settings.isArchived).toBe(true); + }) + .expect(200, done); + }); + }); + + describe('DELETE /pools/:id', () => { + it('HTTP 204', (done) => { + user.delete('/v1/pools/' + poolId) + .set({ 'X-PoolId': poolId, 'Authorization': dashboardAccessToken }) + .expect(204, done); + }); + }); +}); diff --git a/apps/api/src/app/controllers/pools/post.controller.ts b/apps/api/src/app/controllers/pools/post.controller.ts new file mode 100644 index 000000000..2364f62c7 --- /dev/null +++ b/apps/api/src/app/controllers/pools/post.controller.ts @@ -0,0 +1,24 @@ +import { Request, Response } from 'express'; +import { body } from 'express-validator'; +import { getChainId, safeVersion } from '@thxnetwork/api/services/ContractService'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import SafeService from '@thxnetwork/api/services/SafeService'; + +const validation = [body('settings.title').optional().isString().trim().escape().isLength({ max: 50 })]; + +const controller = async (req: Request, res: Response) => { + const { title } = req.body; + const pool = await PoolService.deploy(req.auth.sub, title || 'My Quest Campaign'); + + // Deploy a Safe for the campaign + const poolId = String(pool._id); + const chainId = getChainId(); + const safe = await SafeService.create({ chainId, sub: req.auth.sub, safeVersion, poolId }); + + // Update predicted safe address for pool + await pool.updateOne({ safeAddress: safe.address }); + + res.status(201).json({ ...pool.toJSON(), safeAddress: safe.address, safe }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/quests/delete.controller.ts b/apps/api/src/app/controllers/pools/quests/delete.controller.ts new file mode 100644 index 000000000..de864badd --- /dev/null +++ b/apps/api/src/app/controllers/pools/quests/delete.controller.ts @@ -0,0 +1,26 @@ +import { param } from 'express-validator'; +import { Request, Response } from 'express'; +import { ForbiddenError } from '@thxnetwork/api/util/errors'; +import { QuestVariant } from '@thxnetwork/common/enums'; +import LockService from '@thxnetwork/api/services/LockService'; +import QuestService from '@thxnetwork/api/services/QuestService'; + +const validation = [param('id').isMongoId(), param('variant').isInt(), param('questId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const variant = req.params.variant as unknown as QuestVariant; + const poolId = req.params.id; + const questId = req.params.questId; + + const quest = await QuestService.findById(variant, questId); + if (quest.poolId !== poolId) throw new ForbiddenError('Not your quest.'); + + await quest.deleteOne(); + + // Remove all locks for this quest + await LockService.removeAllLocks(questId); + + res.status(204).end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/quests/entries/entries.router.ts b/apps/api/src/app/controllers/pools/quests/entries/entries.router.ts new file mode 100644 index 000000000..3d16d3285 --- /dev/null +++ b/apps/api/src/app/controllers/pools/quests/entries/entries.router.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import { assertRequestInput, assertPoolAccess, guard } from '@thxnetwork/api/middlewares'; +import * as ListController from './list.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get( + '/', + guard.check(['pools:read']), + assertPoolAccess, + assertRequestInput(ListController.validation), + ListController.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/pools/quests/entries/list.controller.ts b/apps/api/src/app/controllers/pools/quests/entries/list.controller.ts new file mode 100644 index 000000000..fd731be20 --- /dev/null +++ b/apps/api/src/app/controllers/pools/quests/entries/list.controller.ts @@ -0,0 +1,26 @@ +import { Request, Response } from 'express'; +import { param, query } from 'express-validator'; +import { QuestVariant } from '@thxnetwork/common/enums'; +import QuestService from '@thxnetwork/api/services/QuestService'; + +const validation = [ + param('id').isMongoId(), + param('variant').isString(), + param('questId').isMongoId(), + query('page').isInt(), + query('limit').isInt(), +]; + +const controller = async (req: Request, res: Response) => { + const variant = req.params.variant as unknown as QuestVariant; + const questId = req.params.questId as string; + const quest = await QuestService.findById(variant, questId); + const entries = await QuestService.findEntries(quest, { + page: Number(req.query.page), + limit: Number(req.query.limit), + }); + + res.json(entries); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/quests/list.controller.ts b/apps/api/src/app/controllers/pools/quests/list.controller.ts new file mode 100644 index 000000000..0bd892333 --- /dev/null +++ b/apps/api/src/app/controllers/pools/quests/list.controller.ts @@ -0,0 +1,60 @@ +import { param, query } from 'express-validator'; +import { Request, Response } from 'express'; +import { + QuestInvite, + QuestSocial, + QuestCustom, + QuestWeb3, + QuestGitcoin, + QuestDaily, + QuestWebhook, +} from '@thxnetwork/api/models'; + +const validation = [ + param('id').isMongoId(), + query('page').isInt(), + query('limit').isInt(), + query('isPublished') + .optional() + .isBoolean() + .customSanitizer((value) => { + return value && JSON.parse(value); + }), +]; + +const controller = async (req: Request, res: Response) => { + const poolId = req.params.id; + const page = Number(req.query.page); + const limit = Number(req.query.limit); + const $match = { poolId, isPublished: req.query.isPublished }; + const pipeline = [ + { $unionWith: { coll: QuestInvite.collection.name } }, + { $unionWith: { coll: QuestSocial.collection.name } }, + { $unionWith: { coll: QuestCustom.collection.name } }, + { $unionWith: { coll: QuestWeb3.collection.name } }, + { $unionWith: { coll: QuestGitcoin.collection.name } }, + { $unionWith: { coll: QuestWebhook.collection.name } }, + { $match }, + ]; + const arr = await Promise.all( + [QuestDaily, QuestInvite, QuestSocial, QuestCustom, QuestWeb3, QuestGitcoin, QuestWebhook].map( + async (model) => await model.countDocuments($match), + ), + ); + const total = arr.reduce((accumulator, currentValue) => accumulator + currentValue, 0); + const results = await QuestDaily.aggregate([ + ...pipeline, + { $sort: { index: 1 } }, + { $skip: (page - 1) * limit }, + { $limit: limit }, + ]); + + res.json({ + total, + limit, + page, + results, + }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/quests/patch.controller.ts b/apps/api/src/app/controllers/pools/quests/patch.controller.ts new file mode 100644 index 000000000..7a8af5128 --- /dev/null +++ b/apps/api/src/app/controllers/pools/quests/patch.controller.ts @@ -0,0 +1,19 @@ +import { param } from 'express-validator'; +import { Request, Response } from 'express'; +import { QuestVariant } from '@thxnetwork/common/enums'; +import QuestService from '@thxnetwork/api/services/QuestService'; +import * as CreateController from '@thxnetwork/api/controllers/pools/quests/post.controller'; + +const validation = [param('variant').isInt(), param('questId').isMongoId(), ...CreateController.validation]; + +const controller = async (req: Request, res: Response) => { + const variant = req.params.variant as unknown as QuestVariant; + const questId = req.params.questId as string; + + let quest = await QuestService.findById(variant, questId); + quest = await QuestService.update(quest, req.body, req.file); + + res.json(quest); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/quests/post.controller.ts b/apps/api/src/app/controllers/pools/quests/post.controller.ts new file mode 100644 index 000000000..1b5457657 --- /dev/null +++ b/apps/api/src/app/controllers/pools/quests/post.controller.ts @@ -0,0 +1,59 @@ +import { body, param } from 'express-validator'; +import { Request, Response } from 'express'; +import { isValidUrl } from '@thxnetwork/api/util/url'; +import { ChainId } from '@thxnetwork/common/enums'; +import { isAddress } from 'web3-utils'; +import { defaults } from '@thxnetwork/api/util/validation'; +import QuestService from '@thxnetwork/api/services/QuestService'; + +const validationBaseQuest = [ + param('id').isMongoId(), + ...defaults.quest, + // Daily + body('amounts') + .optional() + .custom((amounts) => { + for (const amount of JSON.parse(amounts)) { + if (isNaN(amount)) return false; + } + return true; + }) + .customSanitizer((amounts) => JSON.parse(amounts)), + body('eventName').optional().isString(), + // Invite + body('successUrl') + .optional() + .custom((value) => { + if (value === '' || isValidUrl(value)) return true; + return false; + }), + body('isMandatoryReview').optional().isBoolean(), + // Social + body('kind').optional().isString(), + body('interaction').optional().isNumeric(), + body('content').optional().isString(), + body('contentMetadata').optional().isString(), + // Custom + body('limit').optional().isInt(), + // Web3 + body('contracts') + .optional() + .customSanitizer((contracts) => { + return JSON.parse(contracts).filter((contract: { address: string; chainId: ChainId }) => + isAddress(contract.address), + ); + }), + body('methodName').optional().isString(), + body('threshold').optional().isString(), + // Gitcoin + // +]; + +const validation = [param('id').isMongoId(), ...validationBaseQuest]; + +const controller = async (req: Request, res: Response) => { + const quest = await QuestService.create(req.body.variant, req.params.id, req.body, req.file); + res.status(201).json(quest); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/quests/quests.router.ts b/apps/api/src/app/controllers/pools/quests/quests.router.ts new file mode 100644 index 000000000..522451224 --- /dev/null +++ b/apps/api/src/app/controllers/pools/quests/quests.router.ts @@ -0,0 +1,49 @@ +import express from 'express'; +import { assertRequestInput, assertPoolAccess, guard } from '@thxnetwork/api/middlewares'; +import { upload } from '@thxnetwork/api/util/multer'; + +import * as ListController from './list.controller'; +import * as CreateController from './post.controller'; +import * as UpdateController from './patch.controller'; +import * as RemoveController from './delete.controller'; + +import RouterQuestEntries from './entries/entries.router'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get( + '/', + guard.check(['pools:read']), + upload.single('file'), + assertPoolAccess, + assertRequestInput(ListController.validation), + ListController.controller, +); +router.post( + '/:variant', + guard.check(['pools:read', 'pools:write']), + upload.single('file'), + assertPoolAccess, + assertRequestInput(CreateController.validation), + CreateController.controller, +); +router.patch( + '/:variant/:questId', + guard.check(['pools:read', 'pools:write']), + upload.single('file'), + assertPoolAccess, + assertRequestInput(UpdateController.validation), + UpdateController.controller, +); +router.delete( + '/:variant/:questId', + guard.check(['pools:read', 'pools:write']), + upload.single('file'), + assertPoolAccess, + assertRequestInput(RemoveController.validation), + RemoveController.controller, +); + +router.use('/:variant/:questId/entries', RouterQuestEntries); + +export default router; diff --git a/apps/api/src/app/controllers/pools/rewards/coin-rewards.test.ts b/apps/api/src/app/controllers/pools/rewards/coin-rewards.test.ts new file mode 100644 index 000000000..fc58e0d73 --- /dev/null +++ b/apps/api/src/app/controllers/pools/rewards/coin-rewards.test.ts @@ -0,0 +1,110 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { ChainId, ERC20Type, RewardVariant } from '@thxnetwork/common/enums'; +import { dashboardAccessToken, tokenName, tokenSymbol } from '@thxnetwork/api/util/jest/constants'; +import { isAddress } from 'web3-utils'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { addMinutes } from '@thxnetwork/api/util/date'; +import { createImage } from '@thxnetwork/api/util/jest/images'; +import { ERC20Document } from '@thxnetwork/api/models/ERC20'; +import { RewardCoinDocument } from '@thxnetwork/api/models/RewardCoin'; + +const user = request.agent(app); + +describe('Coin Rewards', () => { + let poolId: string, erc20: ERC20Document, reward: RewardCoinDocument; + + beforeAll(beforeAllCallback); + afterAll(afterAllCallback); + + it('POST /erc20', (done) => { + user.post('/v1/erc20') + .set('Authorization', dashboardAccessToken) + .send({ + chainId: ChainId.Hardhat, + name: tokenName, + symbol: tokenSymbol, + type: ERC20Type.Unlimited, + totalSupply: 0, + }) + .expect(({ body }: request.Response) => { + erc20 = body; + expect(isAddress(body.address)).toBe(true); + }) + .expect(201, done); + }); + + it('POST /pools', (done) => { + user.post('/v1/pools') + .set('Authorization', dashboardAccessToken) + .send({ + chainId: ChainId.Hardhat, + }) + .expect((res: request.Response) => { + expect(isAddress(res.body.safeAddress)).toBe(true); + poolId = res.body._id; + }) + .expect(201, done); + }); + + it('POST /pools/:poolId/rewards/:variant', (done) => { + const title = 'Lorem', + description = 'Ipsum', + expiryDate = addMinutes(new Date(), 30), + pointPrice = 200, + image = createImage(), + amount = '1', + limit = 0, + isPromoted = true, + isPublished = true; + user.post(`/v1/pools/${poolId}/rewards/${RewardVariant.Coin}`) + .set({ Authorization: dashboardAccessToken }) + .attach('file', image, { + filename: 'test.jpg', + contentType: 'image/jpg', + }) + .field({ + title, + description, + image, + limit, + pointPrice, + expiryDate: new Date(expiryDate).toISOString(), + amount, + erc20Id: String(erc20._id), + isPromoted, + isPublished, + }) + .expect((res: request.Response) => { + expect(res.body.uuid).toBeDefined(); + expect(res.body.title).toBe(title); + expect(res.body.description).toBe(description); + expect(res.body.image).toBeDefined(); + expect(res.body.amount).toBe(amount); + expect(res.body.pointPrice).toBe(pointPrice); + expect(new Date(res.body.expiryDate).getDate()).toBe(expiryDate.getDate()); + expect(res.body.limit).toBe(limit); + expect(res.body.isPromoted).toBe(true); + }) + .expect(201, done); + }); + + it('GET /pools/:poolId/rewards', (done) => { + user.get(`/v1/pools/${poolId}/rewards`) + .set({ Authorization: dashboardAccessToken }) + .query({ page: 1, limit: 10, isPublished: true }) + .expect((res: request.Response) => { + expect(res.body.results.length).toBe(1); + expect(res.body.limit).toBe(10); + expect(res.body.total).toBe(1); + reward = res.body.results[0]; + }) + .expect(200, done); + }); + + it('DELETE /pools/:poolId/rewards/:variant', (done) => { + user.delete(`/v1/pools/${poolId}/rewards/${reward.variant}/${reward._id}`) + .set({ Authorization: dashboardAccessToken }) + .expect(204, done); + }); +}); diff --git a/apps/api/src/app/controllers/pools/rewards/delete.controller.ts b/apps/api/src/app/controllers/pools/rewards/delete.controller.ts new file mode 100644 index 000000000..9c04fa72c --- /dev/null +++ b/apps/api/src/app/controllers/pools/rewards/delete.controller.ts @@ -0,0 +1,26 @@ +import { param } from 'express-validator'; +import { Request, Response } from 'express'; +import { RewardVariant } from '@thxnetwork/common/enums'; +import { ForbiddenError } from '@thxnetwork/api/util/errors'; +import { QRCodeEntry } from '@thxnetwork/api/models/QRCodeEntry'; +import RewardService from '@thxnetwork/api/services/RewardService'; + +const validation = [param('id').isMongoId(), param('variant').isInt(), param('rewardId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const poolId = req.params.id; + const variant = req.params.variant as unknown as RewardVariant; + const rewardId = req.params.rewardId; + + const reward = await RewardService.findById(variant, rewardId); + if (reward.poolId !== poolId) throw new ForbiddenError('Not your reward.'); + + await reward.deleteOne(); + + // Delete QR codes for this reward if any + await QRCodeEntry.deleteMany({ rewardId }); + + res.status(204).end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/rewards/discord-role-rewards.router.ts b/apps/api/src/app/controllers/pools/rewards/discord-role-rewards.router.ts new file mode 100644 index 000000000..9fc008b2d --- /dev/null +++ b/apps/api/src/app/controllers/pools/rewards/discord-role-rewards.router.ts @@ -0,0 +1,46 @@ +import { assertPoolAccess, assertRequestInput, guard } from '@thxnetwork/api/middlewares'; +import { upload } from '@thxnetwork/api/util/multer'; +import express from 'express'; +import * as ListDiscordRoleReward from './list.controller'; +import * as ListCouponCodePayments from './payments/list.controller'; +import * as CreateDiscordRoleReward from './post.controller'; +import * as UpdateDiscordRoleReward from './patch.controller'; +import * as RemoveDiscordRoleReward from './delete.controller'; + +const router: express.Router = express.Router(); + +router.get( + '/', + guard.check(['discord_role_rewards:read']), + assertPoolAccess, + assertRequestInput(ListDiscordRoleReward.validation), + ListDiscordRoleReward.controller, +); + +router.get('/payments', ListCouponCodePayments.controller); + +router.patch( + '/:id', + upload.single('file'), + guard.check(['discord_role_rewards:write', 'discord_role_rewards:read']), + assertPoolAccess, + assertRequestInput(UpdateDiscordRoleReward.validation), + UpdateDiscordRoleReward.controller, +); +router.post( + '/', + upload.single('file'), + guard.check(['discord_role_rewards:write', 'discord_role_rewards:read']), + assertPoolAccess, + assertRequestInput(CreateDiscordRoleReward.validation), + CreateDiscordRoleReward.controller, +); +router.delete( + '/:id', + guard.check(['discord_role_rewards:write']), + assertPoolAccess, + assertRequestInput(RemoveDiscordRoleReward.validation), + RemoveDiscordRoleReward.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/pools/rewards/list.controller.ts b/apps/api/src/app/controllers/pools/rewards/list.controller.ts new file mode 100644 index 000000000..c55f2dcde --- /dev/null +++ b/apps/api/src/app/controllers/pools/rewards/list.controller.ts @@ -0,0 +1,70 @@ +import { param, query } from 'express-validator'; +import { Request, Response } from 'express'; +import { + CouponCode, + RewardNFT, + RewardCoupon, + RewardCoin, + RewardDiscordRole, + RewardCustom, + RewardGalachain, +} from '@thxnetwork/api/models'; +import { RewardVariant } from '@thxnetwork/common/enums'; + +const validation = [ + param('id').isMongoId(), + query('page').isInt(), + query('limit').isInt(), + query('isPublished') + .optional() + .isBoolean() + .customSanitizer((value) => { + return value && JSON.parse(value); + }), +]; + +const controller = async (req: Request, res: Response) => { + const poolId = req.params.id; + const page = Number(req.query.page); + const limit = Number(req.query.limit); + const $match = { poolId, isPublished: req.query.isPublished }; + const pipeline = [ + { $unionWith: { coll: RewardNFT.collection.name } }, + { $unionWith: { coll: RewardCoupon.collection.name } }, + { $unionWith: { coll: RewardCustom.collection.name } }, + { $unionWith: { coll: RewardDiscordRole.collection.name } }, + { $unionWith: { coll: RewardGalachain.collection.name } }, + { $match }, + ]; + const arr = await Promise.all( + [RewardCoin, RewardNFT, RewardCoupon, RewardCustom, RewardDiscordRole].map( + async (model) => await model.countDocuments($match), + ), + ); + const total = arr.reduce((accumulator, currentValue) => accumulator + currentValue, 0); + const results = await RewardCoin.aggregate([ + ...pipeline, + { $sort: { index: 1 } }, + { $skip: (page - 1) * limit }, + { $limit: limit }, + ]); + + res.json({ + total, + limit, + page, + results: await Promise.all( + results.map(async (reward) => { + // TODO Move this hack to a service method and make it part of the IRewardService + if (reward.variant === RewardVariant.Coupon) { + const couponCodeCount = await CouponCode.countDocuments({ couponRewardId: reward._id }); + return { ...reward, couponCodeCount }; + } + + return reward; + }), + ), + }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/rewards/nft-rewards.test.ts b/apps/api/src/app/controllers/pools/rewards/nft-rewards.test.ts new file mode 100644 index 000000000..0a7a65171 --- /dev/null +++ b/apps/api/src/app/controllers/pools/rewards/nft-rewards.test.ts @@ -0,0 +1,134 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { ChainId } from '@thxnetwork/common/enums'; +import { dashboardAccessToken } from '@thxnetwork/api/util/jest/constants'; +import { isAddress } from 'web3-utils'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { addMinutes } from '@thxnetwork/api/util/date'; +import { createImage } from '@thxnetwork/api/util/jest/images'; +import { RewardNFTDocument } from '@thxnetwork/api/models/RewardNFT'; +import { ERC721Document } from '@thxnetwork/api/models/ERC721'; +import { ERC721MetadataDocument } from '@thxnetwork/api/models/ERC721Metadata'; +import { RewardVariant } from '@thxnetwork/common/enums'; + +const user = request.agent(app); + +describe('NFT Rewards', () => { + let poolId: string, erc721metadata: ERC721MetadataDocument, erc721: ERC721Document, reward: RewardNFTDocument; + const name = 'Planets of the Galaxy', + symbol = 'GLXY', + description = 'description'; + + beforeAll(beforeAllCallback); + afterAll(afterAllCallback); + + it('POST /erc721', (done) => { + user.post('/v1/erc721') + .set('Authorization', dashboardAccessToken) + .send({ + chainId: ChainId.Hardhat, + name, + symbol, + description, + }) + .expect(({ body }: request.Response) => { + expect(body._id).toBeDefined(); + expect(body.address).toBeDefined(); + erc721 = body; + }) + .expect(201, done); + }); + + it('POST /erc721/:id/metadata', (done) => { + const config = { + name: 'Lorem', + description: 'Lorem ipsum dolor sit.', + imageUrl: 'https://image.com', + externalUrl: 'https://example.com', + }; + + user.post('/v1/erc721/' + erc721._id + '/metadata') + .set('Authorization', dashboardAccessToken) + .send(config) + .expect(({ body }: request.Response) => { + expect(body._id).toBeDefined(); + expect(body.name).toBe(config.name); + expect(body.description).toBe(config.description); + expect(body.image).toBe(config.imageUrl); + expect(body.externalUrl).toBe(config.externalUrl); + erc721metadata = body; + }) + .expect(201, done); + }); + + it('POST /pools', (done) => { + user.post('/v1/pools') + .set('Authorization', dashboardAccessToken) + .send({ + chainId: ChainId.Hardhat, + }) + .expect(({ body }: request.Response) => { + expect(isAddress(body.safeAddress)).toBe(true); + poolId = body._id; + }) + .expect(201, done); + }); + + it('POST /pools/:poolId/rewards/:variant', (done) => { + const expiryDate = addMinutes(new Date(), 30); + const image = createImage(); + const config = { + title: 'Lorem', + description: 'Lorem ipsum', + erc721Id: String(erc721._id), + metadataId: erc721metadata._id, + pointPrice: 200, + expiryDate: new Date(expiryDate).toISOString(), + limit: 0, + claimAmount: 0, + isPromoted: true, + variant: RewardVariant.NFT, + isPublished: true, + }; + user.post(`/v1/pools/${poolId}/rewards/${RewardVariant.NFT}`) + .set({ Authorization: dashboardAccessToken }) + .attach('file', image, { + filename: 'test.jpg', + contentType: 'image/jpg', + }) + .field(config) + .expect((res: request.Response) => { + expect(res.body.uuid).toBeDefined(); + expect(res.body.title).toBe(config.title); + expect(res.body.description).toBe(config.description); + expect(res.body.image).toBeDefined(); + expect(res.body.pointPrice).toBe(config.pointPrice); + expect(new Date(res.body.expiryDate).getDate()).toBe(expiryDate.getDate()); + expect(res.body.limit).toBe(config.limit); + expect(res.body.claimAmount).toBe(config.claimAmount); + expect(res.body.isPromoted).toBe(config.isPromoted); + expect(res.body.erc721Id).toBe(erc721._id); + expect(res.body.metadataId).toBe(erc721metadata._id); + }) + .expect(201, done); + }); + + it('GET /pools/:poolId/rewards?page=:page&limit=:limit', (done) => { + user.get(`/v1/pools/${poolId}/rewards`) + .query({ page: 1, limit: 10, isPublished: true }) + .set({ Authorization: dashboardAccessToken }) + .expect((res: request.Response) => { + expect(res.body.results.length).toBe(1); + expect(res.body.limit).toBe(10); + expect(res.body.total).toBe(1); + reward = res.body.results[0]; + }) + .expect(200, done); + }); + + it('DELETE /rewards/:id', (done) => { + user.delete(`/v1/pools/${poolId}/rewards/${reward.variant}/${reward._id}`) + .set({ Authorization: dashboardAccessToken }) + .expect(204, done); + }); +}); diff --git a/apps/api/src/app/controllers/pools/rewards/patch.controller.ts b/apps/api/src/app/controllers/pools/rewards/patch.controller.ts new file mode 100644 index 000000000..f1805678f --- /dev/null +++ b/apps/api/src/app/controllers/pools/rewards/patch.controller.ts @@ -0,0 +1,19 @@ +import { param } from 'express-validator'; +import { Request, Response } from 'express'; +import { RewardVariant } from '@thxnetwork/common/enums'; +import RewardService from '@thxnetwork/api/services/RewardService'; +import * as CreateController from '@thxnetwork/api/controllers/pools/rewards/post.controller'; + +const validation = [param('rewardId').isMongoId(), ...CreateController.validation]; + +const controller = async (req: Request, res: Response) => { + const variant = req.params.variant as unknown as RewardVariant; + const rewardId = req.params.rewardId as string; + + let reward = await RewardService.findById(variant, rewardId); + reward = await RewardService.update(reward, req.body, req.file); + + res.json(reward); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/rewards/payments/list.controller.ts b/apps/api/src/app/controllers/pools/rewards/payments/list.controller.ts new file mode 100644 index 000000000..98a713c9b --- /dev/null +++ b/apps/api/src/app/controllers/pools/rewards/payments/list.controller.ts @@ -0,0 +1,30 @@ +import { Request, Response } from 'express'; +import { param, query } from 'express-validator'; +import { RewardVariant } from '@thxnetwork/common/enums'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import RewardService from '@thxnetwork/api/services/RewardService'; + +const validation = [ + param('id').isMongoId(), + param('variant').isString(), + param('rewardId').isMongoId(), + query('page').isInt(), + query('limit').isInt(), + query('query').isString(), +]; + +const controller = async (req: Request, res: Response) => { + const variant = req.params.variant as unknown as RewardVariant; + const reward = await RewardService.findById(variant, req.params.rewardId); + if (!reward) throw new NotFoundError('Reward not found'); + + const payments = await RewardService.findPayments(reward, { + page: Number(req.query.page), + limit: Number(req.query.limit), + query: req.query.query as string, + }); + + res.json(payments); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/rewards/payments/payments.router.ts b/apps/api/src/app/controllers/pools/rewards/payments/payments.router.ts new file mode 100644 index 000000000..d280e4317 --- /dev/null +++ b/apps/api/src/app/controllers/pools/rewards/payments/payments.router.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import { assertRequestInput, assertPoolAccess, guard } from '@thxnetwork/api/middlewares'; +import * as ListPayments from './list.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get( + '/', + guard.check(['pools:read']), + assertPoolAccess, + assertRequestInput(ListPayments.validation), + ListPayments.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/pools/rewards/post.controller.ts b/apps/api/src/app/controllers/pools/rewards/post.controller.ts new file mode 100644 index 000000000..aeb30d571 --- /dev/null +++ b/apps/api/src/app/controllers/pools/rewards/post.controller.ts @@ -0,0 +1,57 @@ +import { body, param } from 'express-validator'; +import { Request, Response } from 'express'; +import { defaults } from '@thxnetwork/api/util/validation'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { RewardVariant } from '@thxnetwork/common/enums'; +import RewardService from '@thxnetwork/api/services/RewardService'; +import PoolService from '@thxnetwork/api/services/PoolService'; + +const validationBaseQuest = [ + param('id').isMongoId(), + ...defaults.reward, + + // Coin + body('erc20Id').optional().isMongoId(), + body('amount').optional().isInt({ gt: 0 }), + + // NFT + body('erc721Id').optional().isString(), + body('erc1155Id').optional().isString(), + body('metadataIds').optional().isString(), + body('tokenId').optional().isString(), + + // Coupon + body('webshopURL').optional().isURL({ require_tld: false }), + body('codes') + .optional() + .custom((value: string) => value && Array.isArray(JSON.parse(value))) + .customSanitizer((value: string) => value && JSON.parse(value)), + + // Custom + body('webhookId').optional().isMongoId(), + body('metadata').optional().isString(), + // DiscordRole + body('discordRoleId').optional().isString(), + // Galachain + body('contractChannelName').optional().isString(), + body('contractChaincodeName').optional().isString(), + body('contractContractName').optional().isString(), + body('tokenCollection').optional().isString(), + body('tokenCategory').optional().isString(), + body('tokenType').optional().isString(), + body('tokenAdditionalKey').optional().isString(), + body('amount').optional().isInt({ gt: 0 }), +]; + +const validation = [param('id').isMongoId(), ...validationBaseQuest]; + +const controller = async (req: Request, res: Response) => { + const pool = await PoolService.getById(req.params.id); + if (!pool) throw new NotFoundError('Could not find pool'); + const variant = req.params.variant as unknown as RewardVariant; + const reward = await RewardService.create(variant, req.params.id, req.body, req.file); + + res.status(201).json(reward); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/rewards/rewards.router.ts b/apps/api/src/app/controllers/pools/rewards/rewards.router.ts new file mode 100644 index 000000000..c3c5370a2 --- /dev/null +++ b/apps/api/src/app/controllers/pools/rewards/rewards.router.ts @@ -0,0 +1,49 @@ +import express from 'express'; +import { assertRequestInput, assertPoolAccess, guard } from '@thxnetwork/api/middlewares'; +import { upload } from '@thxnetwork/api/util/multer'; + +import * as ListController from './list.controller'; +import * as UpdateController from './patch.controller'; +import * as CreateController from './post.controller'; +import * as RemoveController from './delete.controller'; + +import RouterRewardPayments from './payments/payments.router'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get( + '/', + guard.check(['pools:read']), + upload.single('file'), + assertPoolAccess, + assertRequestInput(ListController.validation), + ListController.controller, +); +router.post( + '/:variant', + guard.check(['pools:read', 'pools:write']), + upload.single('file'), + assertPoolAccess, + assertRequestInput(CreateController.validation), + CreateController.controller, +); +router.patch( + '/:variant/:rewardId', + guard.check(['pools:read', 'pools:write']), + upload.single('file'), + assertPoolAccess, + assertRequestInput(UpdateController.validation), + UpdateController.controller, +); +router.delete( + '/:variant/:rewardId', + guard.check(['pools:read', 'pools:write']), + upload.single('file'), + assertPoolAccess, + assertRequestInput(RemoveController.validation), + RemoveController.controller, +); + +router.use('/:variant/:rewardId/payments', RouterRewardPayments); + +export default router; diff --git a/apps/api/src/app/controllers/pools/wallets/list.controller.ts b/apps/api/src/app/controllers/pools/wallets/list.controller.ts new file mode 100644 index 000000000..3c3b0450a --- /dev/null +++ b/apps/api/src/app/controllers/pools/wallets/list.controller.ts @@ -0,0 +1,12 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { Wallet } from '@thxnetwork/api/models'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const wallets = await Wallet.find({ poolId: req.params.id }); + res.json(wallets); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/pools/wallets/wallets.router.ts b/apps/api/src/app/controllers/pools/wallets/wallets.router.ts new file mode 100644 index 000000000..c6cc65c99 --- /dev/null +++ b/apps/api/src/app/controllers/pools/wallets/wallets.router.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import { assertRequestInput, assertPoolAccess, guard } from '@thxnetwork/api/middlewares'; +import * as ListWallets from './list.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get( + '/', + guard.check(['pools:read']), + assertPoolAccess, + assertRequestInput(ListWallets.validation), + ListWallets.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/qr-codes/collect/post.controller.ts b/apps/api/src/app/controllers/qr-codes/collect/post.controller.ts new file mode 100644 index 000000000..d5dc5f47e --- /dev/null +++ b/apps/api/src/app/controllers/qr-codes/collect/post.controller.ts @@ -0,0 +1,64 @@ +import { Request, Response } from 'express'; +import { param, query } from 'express-validator'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { RewardNFT, RewardNFTPayment, QRCodeEntry, ERC721Metadata } from '@thxnetwork/api/models'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; +import SafeService from '@thxnetwork/api/services/SafeService'; +import WalletService from '@thxnetwork/api/services/WalletService'; + +const validation = [param('uuid').isUUID(4), query('walletId').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + let entry = await QRCodeEntry.findOne({ uuid: req.params.uuid }); + if (!entry) throw new BadRequestError('This claim URL is invalid.'); + // Can not be claimed when sub is set for this claim URL and claim amount is greater than 1 + if (entry.sub) throw new ForbiddenError('This NFT is claimed already.'); + + const reward = await RewardNFT.findById(entry.rewardId); + if (!reward) throw new BadRequestError('Reward not found'); + // Can be claimed only if point price is 0 + if (reward.pointPrice > 0) throw new ForbiddenError('Reward needs to be purchased with points.'); + + const pool = await PoolService.getById(reward.poolId); + if (!pool) throw new BadRequestError('Campaign not found.'); + + const safe = await SafeService.findOneByPool(pool, pool.chainId); + if (!safe) throw new BadRequestError('Safe not found.'); + + // Find wallet for the authenticated user + const wallet = await WalletService.findById(req.query.walletId as string); + if (!wallet) throw new NotFoundError('Wallet not found'); + + // Mint an NFT token if the erc721 and metadata for the claim exists. + const metadata = await ERC721Metadata.findById(reward.metadataId); + if (!metadata) throw new NotFoundError('Metadata not found'); + + const erc721 = await ERC721Service.findById(metadata.erc721Id); + if (!erc721) throw new NotFoundError('ERC721 not found'); + + // Mint the NFT + const token = await ERC721Service.mint(safe, erc721, wallet, metadata); + + // Create a payment to register a completed claim. + const payment = await RewardNFTPayment.create({ + sub: req.auth.sub, + rewardId: reward._id, + amount: reward.pointPrice, + poolId: pool._id, + }); + + // Mark claim as claimed by setting sub + entry = await QRCodeEntry.findByIdAndUpdate(entry._id, { sub: req.auth.sub, claimedAt: new Date() }, { new: true }); + + return res.json({ + erc721, + entry, + payment, + token, + metadata, + reward, + }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/qr-codes/delete.controller.ts b/apps/api/src/app/controllers/qr-codes/delete.controller.ts new file mode 100644 index 000000000..45160b29d --- /dev/null +++ b/apps/api/src/app/controllers/qr-codes/delete.controller.ts @@ -0,0 +1,24 @@ +import { QRCodeEntry, RewardNFT } from '@thxnetwork/api/models'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import { ForbiddenError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { Request, Response } from 'express'; +import { param } from 'express-validator'; + +const validation = [param('uuid').isUUID(4)]; + +const controller = async (req: Request, res: Response) => { + const entry = await QRCodeEntry.findOne({ uuid: req.params.uuid }); + if (!entry) throw new NotFoundError('QR Code Entry not found'); + + const reward = await RewardNFT.findById(entry.rewardId); + if (!reward) throw new NotFoundError('Reward not found'); + + const isAllowed = await PoolService.isSubjectAllowed(req.auth.sub, reward.poolId); + if (!isAllowed) throw new ForbiddenError('Not allowed for delete.'); + + await entry.deleteOne(); + + res.status(204).end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/qr-codes/get.controller.ts b/apps/api/src/app/controllers/qr-codes/get.controller.ts new file mode 100644 index 000000000..a753047f9 --- /dev/null +++ b/apps/api/src/app/controllers/qr-codes/get.controller.ts @@ -0,0 +1,29 @@ +import { Request, Response } from 'express'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { param } from 'express-validator'; +import { QRCodeEntry, RewardNFT, ERC721Metadata } from '@thxnetwork/api/models'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; +import PoolService from '@thxnetwork/api/services/PoolService'; + +const validation = [param('uuid').exists().isUUID(4)]; + +const controller = async (req: Request, res: Response) => { + const entry = await QRCodeEntry.findOne({ uuid: req.params.uuid }); + if (!entry) throw new NotFoundError('QR code entry not found'); + + const reward = await RewardNFT.findById(entry.rewardId); + if (!reward) throw new NotFoundError('Reward not found'); + + const pool = await PoolService.getById(reward.poolId); + if (!pool) throw new NotFoundError('Pool not found'); + + const erc721 = await ERC721Service.findById(reward.erc721Id); + if (!erc721) throw new NotFoundError('ERC721 not found'); + + const metadata = await ERC721Metadata.findById(reward.metadataId); + if (!metadata) throw new NotFoundError('Metadata not found'); + + return res.json({ pool, entry, erc721, metadata }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/qr-codes/list.controller.ts b/apps/api/src/app/controllers/qr-codes/list.controller.ts new file mode 100644 index 000000000..083e5f07c --- /dev/null +++ b/apps/api/src/app/controllers/qr-codes/list.controller.ts @@ -0,0 +1,44 @@ +import { QRCodeEntry, RewardNFT } from '@thxnetwork/api/models'; +import { ForbiddenError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { Request, Response } from 'express'; +import { query } from 'express-validator'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import PoolService from '@thxnetwork/api/services/PoolService'; + +const validation = [query('rewardId').isMongoId(), query('page').isInt(), query('limit').isInt()]; + +const controller = async (req: Request, res: Response) => { + const page = Number(req.query.page); + const limit = Number(req.query.limit); + const rewardId = req.query.rewardId; + + const reward = await RewardNFT.findById(rewardId); + if (!reward) throw new NotFoundError('Reward not found'); + + const isAllowed = await PoolService.isSubjectAllowed(req.auth.sub, reward.poolId); + if (!isAllowed) throw new ForbiddenError('Reward not accessible.'); + + const total = await QRCodeEntry.countDocuments({ rewardId }); + const entries = await QRCodeEntry.find({ rewardId }) + .limit(limit) + .skip((page - 1) * limit); + const subs = entries.map(({ sub }) => sub); + const accounts = await AccountProxy.find({ subs }); + const results = entries.map((entry) => { + const account = accounts.find((account) => account.sub === entry.sub); + return Object.assign(entry.toJSON(), { account }); + }); + const meta = { + participantCount: await QRCodeEntry.countDocuments({ rewardId, sub: { $exists: true } }), + }; + + res.json({ + total, + limit, + page, + results, + meta, + }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/qr-codes/post.controller.ts b/apps/api/src/app/controllers/qr-codes/post.controller.ts new file mode 100644 index 000000000..74bb919e0 --- /dev/null +++ b/apps/api/src/app/controllers/qr-codes/post.controller.ts @@ -0,0 +1,26 @@ +import { RewardNFT } from '@thxnetwork/api/models'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { Request, Response } from 'express'; +import { body } from 'express-validator'; +import QRCodeService from '@thxnetwork/api/services/ClaimService'; + +const validation = [ + body('rewardId').isMongoId(), + body('claimAmount').isInt(), + body('redirectURL').isURL({ require_tld: false }), +]; + +const controller = async (req: Request, res: Response) => { + const rewardId = req.body.rewardId; + const redirectURL = req.body.redirectURL; + const claimAmount = Number(req.body.claimAmount); + + const reward = await RewardNFT.findById(rewardId); + if (!reward) throw new NotFoundError('Reward not found'); + + const entries = await QRCodeService.create({ rewardId, redirectURL }, claimAmount); + + res.status(201).json(entries); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/qr-codes/qr-codes.router.ts b/apps/api/src/app/controllers/qr-codes/qr-codes.router.ts new file mode 100644 index 000000000..8a7a4f044 --- /dev/null +++ b/apps/api/src/app/controllers/qr-codes/qr-codes.router.ts @@ -0,0 +1,33 @@ +import express from 'express'; +import { assertRequestInput, checkJwt, corsHandler, guard } from '@thxnetwork/api/middlewares'; +import * as ListEntry from './list.controller'; +import * as CreateEntry from './post.controller'; +import * as ReadEntry from './get.controller'; +import * as DeleteEntryController from './delete.controller'; +import * as ReadRedirectEntry from './redirect/get.controller'; +import * as UpdateEntryController from './collect/post.controller'; + +const router: express.Router = express.Router(); + +router.get('/:uuid', assertRequestInput(ReadEntry.validation), ReadEntry.controller); +router.get('/r/:uuid', assertRequestInput(ReadRedirectEntry.validation), ReadRedirectEntry.controller); + +router.use(checkJwt, corsHandler); +router.get('/', guard.check(['pools:read']), assertRequestInput(ListEntry.validation), ListEntry.controller); +router.post('/', guard.check(['pools:read']), assertRequestInput(CreateEntry.validation), CreateEntry.controller); + +router.patch( + '/:uuid', + guard.check(['claims:read']), + assertRequestInput(UpdateEntryController.validation), + UpdateEntryController.controller, +); + +router.delete( + '/:uuid', + guard.check(['pools:write']), + assertRequestInput(DeleteEntryController.validation), + DeleteEntryController.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/qr-codes/qr-codes.test.ts b/apps/api/src/app/controllers/qr-codes/qr-codes.test.ts new file mode 100644 index 000000000..d4d5bf15c --- /dev/null +++ b/apps/api/src/app/controllers/qr-codes/qr-codes.test.ts @@ -0,0 +1,192 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { ChainId, NFTVariant, RewardVariant } from '@thxnetwork/common/enums'; +import { sub, dashboardAccessToken, widgetAccessToken, widgetAccessToken2 } from '@thxnetwork/api/util/jest/constants'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { + PoolDocument, + ERC721Metadata, + ERC721MetadataDocument, + QRCodeEntryDocument, + ERC721Document, + RewardNFTDocument, +} from '@thxnetwork/api/models'; +import { IPFS_BASE_URL } from '@thxnetwork/api/config/secrets'; +import { safeVersion } from '@thxnetwork/api/services/ContractService'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { poll } from '@thxnetwork/api/util/polling'; +import { WalletDocument } from '@thxnetwork/api/models/Wallet'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import ERC721Service from '@thxnetwork/api/services/ERC721Service'; +import SafeService from '@thxnetwork/api/services/SafeService'; + +const user = request.agent(app); + +describe('QR Codes', () => { + let poolId: string, + pool: PoolDocument, + erc721: ERC721Document, + reward: RewardNFTDocument, + metadata: ERC721MetadataDocument, + wallet: WalletDocument, + qrcodes: QRCodeEntryDocument[]; + + const chainId = ChainId.Hardhat; + + beforeAll(async () => { + await beforeAllCallback(); + + pool = await PoolService.deploy(sub, 'My Reward Campaign'); + poolId = String(pool._id); + + const safe = await SafeService.create({ sub, chainId, safeVersion, poolId }); + + // Wait for campaign safe to be deployed + const { web3 } = getProvider(ChainId.Hardhat); + await poll( + () => web3.eth.getCode(safe.address), + (data: string) => data === '0x', + 1000, + ); + + erc721 = await ERC721Service.deploy({ + variant: NFTVariant.ERC721, + sub, + chainId, + name: 'Test Collection', + symbol: 'TST', + description: '', + baseURL: 'https://example.com', + archived: false, + logoImgUrl: 'https://img.url', + }); + metadata = await ERC721Metadata.create({ + erc721Id: String(erc721._id), + name: 'Token Silver', + image: IPFS_BASE_URL + 'abcdef', + imageUrl: 'https://image.com/image.jpg', + description: 'Lorem ipsum dolor sit amet', + externalUrl: 'https://example.com', + }); + wallet = await SafeService.findOne({ sub }); + }); + afterAll(afterAllCallback); + + it('POST /pools/:poolId/rewards/:variant', (done) => { + user.post(`/v1/pools/${poolId}/rewards/${RewardVariant.NFT}`) + .set({ Authorization: dashboardAccessToken }) + .send({ + title: '', + description: '', + pointPrice: 0, + limit: 0, + variant: RewardVariant.NFT, + erc721Id: erc721._id, + metadataId: metadata._id, + }) + .expect(({ body }: request.Response) => { + expect(body._id).toBeDefined(); + reward = body; + }) + .expect(201, done); + }); + + it('POST /qr-codes', (done) => { + user.post(`/v1/qr-codes`) + .set({ Authorization: dashboardAccessToken }) + .send({ + rewardId: reward._id, + claimAmount: 10, + redirectURL: 'https://example.com/redirect', + }) + .expect(({ body }: request.Response) => { + expect(body).toHaveLength(10); + }) + .expect(201, done); + }); + + it('GET /qr-codes?rewardId=:rewardId&page=:page&limit=:limit', (done) => { + user.get(`/v1/qr-codes`) + .set({ Authorization: dashboardAccessToken }) + .query({ + rewardId: reward._id, + page: 1, + limit: 15, + }) + .expect(({ body }: request.Response) => { + expect(body.total).toBe(10); + expect(body.results).toHaveLength(10); + qrcodes = body.results; + }) + .expect(200, done); + }); + + it('GET /qr-codes/:uuid', (done) => { + user.get(`/v1/qr-codes/${qrcodes[0].uuid}`) + .expect(({ body }: request.Response) => { + expect(body.entry).toBeDefined(); + expect(body.erc721).toBeDefined(); + expect(body.metadata).toBeDefined(); + }) + .expect(200, done); + }); + + it('PATCH /qr-codes/:uuid should succeed', (done) => { + user.patch(`/v1/qr-codes/${qrcodes[0].uuid}`) + .query({ walletId: String(wallet._id) }) + .set({ Authorization: widgetAccessToken }) + .expect(({ body }: request.Response) => { + expect(body.erc721).toBeDefined(); + expect(body.entry).toBeDefined(); + expect(body.payment).toBeDefined(); + expect(body.token).toBeDefined(); + expect(body.metadata).toBeDefined(); + expect(body.reward).toBeDefined(); + }) + .expect(200, done); + }); + + it('GET /qr-codes/:uuid should return sub', (done) => { + user.get(`/v1/qr-codes/${qrcodes[0].uuid}`) + .set({ Authorization: widgetAccessToken }) + .expect(({ body }: request.Response) => { + expect(body.entry).toBeDefined(); + expect(body.entry.sub).toBeDefined(); + expect(body.erc721).toBeDefined(); + expect(body.metadata).toBeDefined(); + }) + .expect(200, done); + }); + + it('PATCH /qr-codes/:uuid should fail', (done) => { + user.patch(`/v1/qr-codes/${qrcodes[0].uuid}`) + .query({ walletId: String(wallet._id) }) + .set({ Authorization: widgetAccessToken }) + .expect(({ body }: request.Response) => { + expect(body.error.message).toBe('This NFT is claimed already.'); + }) + .expect(403, done); + }); + + it('PATCH /qr-codes/:uuid from other account should also fail', (done) => { + user.patch(`/v1/qr-codes/${qrcodes[0].uuid}`) + .query({ walletId: String(wallet._id) }) + .set({ Authorization: widgetAccessToken2 }) + .expect(({ body }: request.Response) => { + expect(body.error.message).toBe('This NFT is claimed already.'); + }) + .expect(403, done); + }); + + it('First attempt other claim for other account should succeed', (done) => { + user.patch(`/v1/qr-codes/${qrcodes[1].uuid}`) + .query({ walletId: String(wallet._id) }) + .set({ Authorization: widgetAccessToken2 }) + .expect(({ body }: request.Response) => { + expect(body.entry).toBeDefined(); + expect(body.erc721).toBeDefined(); + expect(body.metadata).toBeDefined(); + }) + .expect(200, done); + }); +}); diff --git a/apps/api/src/app/controllers/qr-codes/redirect/get.controller.ts b/apps/api/src/app/controllers/qr-codes/redirect/get.controller.ts new file mode 100644 index 000000000..d85966835 --- /dev/null +++ b/apps/api/src/app/controllers/qr-codes/redirect/get.controller.ts @@ -0,0 +1,35 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { RewardNFT, QRCodeEntry } from '@thxnetwork/api/models'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import { WIDGET_URL } from '@thxnetwork/api/config/secrets'; + +const validation = [param('uuid').isUUID(4)]; + +const controller = async (req: Request, res: Response) => { + const entry = await QRCodeEntry.findOne({ uuid: req.params.uuid }); + if (!entry) throw new NotFoundError('QR code entry not found'); + + const reward = await RewardNFT.findById(entry.rewardId); + if (!reward) throw new NotFoundError('Reward not found'); + + // If redirectURL is set in entry, redirect immediately + if (entry.redirectURL) { + const url = new URL(entry.redirectURL); + url.searchParams.append('thx_widget_path', `/c/${req.params.uuid}`); + + return res.redirect(302, url.toString()); + } + + // Redirect to campaign URl if not present in the entry + const pool = await PoolService.getById(reward.poolId); + if (!pool) throw new NotFoundError('Pool not found.'); + + const url = new URL(WIDGET_URL); + url.pathname = `/c/${pool.settings.slug}/c/${req.params.uuid}`; + + return res.redirect(302, url.toString()); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/quests/custom/custom.router.ts b/apps/api/src/app/controllers/quests/custom/custom.router.ts new file mode 100644 index 000000000..21d2ab6c9 --- /dev/null +++ b/apps/api/src/app/controllers/quests/custom/custom.router.ts @@ -0,0 +1,16 @@ +import express from 'express'; +import * as CreateEntry from './entries/post.controller'; +import { assertRequestInput, assertAccount } from '@thxnetwork/api/middlewares'; +import { limitInSeconds } from '@thxnetwork/api/util/ratelimiter'; + +export const router: express.Router = express.Router({ mergeParams: true }); + +router.post( + '/:id/entries', + limitInSeconds(3), + assertRequestInput(CreateEntry.validation), + assertAccount, + CreateEntry.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/quests/custom/custom.test.ts b/apps/api/src/app/controllers/quests/custom/custom.test.ts new file mode 100644 index 000000000..16c2328f6 --- /dev/null +++ b/apps/api/src/app/controllers/quests/custom/custom.test.ts @@ -0,0 +1,84 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { v4 } from 'uuid'; +import { QuestVariant } from '@thxnetwork/common/enums'; +import { dashboardAccessToken, userWalletAddress2, widgetAccessToken2 } from '@thxnetwork/api/util/jest/constants'; +import { isAddress } from 'web3-utils'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { PoolDocument, QuestCustom } from '@thxnetwork/api/models'; + +const user = request.agent(app); + +describe('Quests Custom ', () => { + let pool: PoolDocument, customQuest: TQuestCustom; + const eventName = v4(); + + beforeAll(beforeAllCallback); + afterAll(afterAllCallback); + + it('POST /pools', (done) => { + user.post('/v1/pools') + .set('Authorization', dashboardAccessToken) + .send() + .expect((res: request.Response) => { + expect(isAddress(res.body.safeAddress)).toBe(true); + pool = res.body; + }) + .expect(201, done); + }); + + it('POST /pools/:id/quests', (done) => { + user.post(`/v1/pools/${pool._id}/quests/${QuestVariant.Custom}`) + .set({ Authorization: dashboardAccessToken }) + .send({ + variant: QuestVariant.Custom, + title: 'Expiration date is next 30 min', + description: 'Lorem ipsum dolor sit amet', + amount: 100, + limit: 1, + index: 0, + eventName, + }) + .expect(async (res: request.Response) => { + expect(res.body.uuid).toBeDefined(); + expect(res.body.amount).toBe(100); + customQuest = res.body; + await QuestCustom.findByIdAndUpdate(customQuest._id, { eventName: customQuest.uuid }); + }) + .expect(201, done); + }); + + describe('Qualify (to be deprecated)', () => { + it('POST /webhook/milestone/:token/claim', (done) => { + user.post(`/v1/webhook/milestone/${customQuest.uuid}/claim`) + .send({ + address: userWalletAddress2, + }) + .expect(201, done); + }); + + it('POST /webhook/milestone/:token/claim second time should also succeed', (done) => { + user.post(`/v1/webhook/milestone/${customQuest.uuid}/claim`) + .send({ + address: userWalletAddress2, + }) + .expect(201, done); + }); + }); + + describe('Collect', () => { + it('GET /account to update identity', (done) => { + user.get(`/v1/account`) + .set({ 'X-PoolId': pool._id, 'Authorization': widgetAccessToken2 }) + .expect(200, done); + }); + + it('POST /quests/custom/:id/entries', async () => { + const { status } = await user + .post(`/v1/quests/custom/${customQuest._id}/entries`) + .set({ 'X-PoolId': pool._id, 'Authorization': widgetAccessToken2 }) + .send({ recaptcha: 'test' }); + expect(status).toBe(200); + }); + }); +}); diff --git a/apps/api/src/app/controllers/quests/custom/entries/post.controller.ts b/apps/api/src/app/controllers/quests/custom/entries/post.controller.ts new file mode 100644 index 000000000..bec226ac7 --- /dev/null +++ b/apps/api/src/app/controllers/quests/custom/entries/post.controller.ts @@ -0,0 +1,38 @@ +import { Request, Response } from 'express'; +import { QuestCustom } from '@thxnetwork/api/models'; +import { body, param } from 'express-validator'; +import { JobType, QuestVariant } from '@thxnetwork/common/enums'; +import { agenda } from '@thxnetwork/api/util/agenda'; +import QuestService from '@thxnetwork/api/services/QuestService'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; + +const validation = [param('id').isMongoId(), body('recaptcha').isString()]; + +const controller = async ({ params, body, account }: Request, res: Response) => { + const quest = await QuestCustom.findById(params.id); + if (!quest) throw new NotFoundError('Quest not found.'); + + const data = { recaptcha: body.recaptcha }; + + // Running separately to avoid issues when getting validation results from Discord interactions + const isRealUser = await QuestService.isRealUser(quest.variant, { quest, account, data }); + if (!isRealUser.result) return res.json({ error: isRealUser.reason }); + + const { result, reason } = await QuestService.getValidationResult(quest.variant, { + quest, + account, + data, + }); + if (!result) return res.json({ error: reason }); + + const job = await agenda.now(JobType.CreateQuestEntry, { + variant: QuestVariant.Custom, + questId: String(quest._id), + sub: account.sub, + data: { ...data, isClaimed: true }, + }); + + res.json({ jobId: job.attrs._id }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/quests/daily/daily.router.ts b/apps/api/src/app/controllers/quests/daily/daily.router.ts new file mode 100644 index 000000000..9ab145733 --- /dev/null +++ b/apps/api/src/app/controllers/quests/daily/daily.router.ts @@ -0,0 +1,16 @@ +import express from 'express'; +import * as CreateEntry from './entries/post.controller'; +import { assertAccount, assertRequestInput } from '@thxnetwork/api/middlewares'; +import { limitInSeconds } from '@thxnetwork/api/util/ratelimiter'; + +export const router: express.Router = express.Router({ mergeParams: true }); + +router.post( + '/:id/entries', + limitInSeconds(3), + assertRequestInput(CreateEntry.validation), + assertAccount, + CreateEntry.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/quests/daily/entries/post.controller.ts b/apps/api/src/app/controllers/quests/daily/entries/post.controller.ts new file mode 100644 index 000000000..89ebe229f --- /dev/null +++ b/apps/api/src/app/controllers/quests/daily/entries/post.controller.ts @@ -0,0 +1,40 @@ +import { Request, Response } from 'express'; +import { body, param } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { JobType, QuestVariant } from '@thxnetwork/common/enums'; +import { agenda } from '@thxnetwork/api/util/agenda'; +import { QuestDaily } from '@thxnetwork/api/models'; +import { getIP } from '@thxnetwork/api/util/ip'; +import QuestService from '@thxnetwork/api/services/QuestService'; + +const validation = [param('id').isMongoId(), body('recaptcha').isString()]; + +const controller = async (req: Request, res: Response) => { + const { params, account } = req; + const quest = await QuestDaily.findById(params.id); + if (!quest) throw new NotFoundError('Could not find the Daily Reward'); + + // Only do this is no event requirement is set + const data = { recaptcha: req.body.recaptcha, metadata: {} }; + if (!quest.eventName) { + data.metadata['ip'] = getIP(req); + } + + // Running separately to avoid issues when getting validation results from Discord interactions + const isRealUser = await QuestService.isRealUser(quest.variant, { quest, account, data }); + if (!isRealUser.result) return res.json({ error: isRealUser.reason }); + + const { result, reason } = await QuestService.getValidationResult(quest.variant, { quest, account, data }); + if (!result) return res.json({ error: reason }); + + const job = await agenda.now(JobType.CreateQuestEntry, { + variant: QuestVariant.Daily, + questId: String(quest._id), + sub: account.sub, + data, + }); + + res.json({ jobId: job.attrs._id }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/quests/gitcoin/entries/post.controller.ts b/apps/api/src/app/controllers/quests/gitcoin/entries/post.controller.ts new file mode 100644 index 000000000..832bd3589 --- /dev/null +++ b/apps/api/src/app/controllers/quests/gitcoin/entries/post.controller.ts @@ -0,0 +1,50 @@ +import { Request, Response } from 'express'; +import { body, param } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { recoverSigner } from '@thxnetwork/api/util/network'; +import { JobType, QuestVariant } from '@thxnetwork/common/enums'; +import { QuestGitcoin } from '@thxnetwork/api/models'; +import { agenda } from '@thxnetwork/api/util/agenda'; +import QuestService from '@thxnetwork/api/services/QuestService'; +import GitcoinService from '@thxnetwork/api/services/GitcoinService'; + +const validation = [ + param('id').isMongoId(), + body('signature').isString(), + body('chainId').isInt(), + body('recaptcha').isString(), +]; + +const controller = async ({ account, body, params }: Request, res: Response) => { + const quest = await QuestGitcoin.findById(params.id); + if (!quest) throw new NotFoundError('Quest not found'); + + const address = recoverSigner(body.message, body.signature); + const data = { recaptcha: body.recaptcha, metadata: { address } }; + + // Running separately to avoid issues when getting validation results from Discord interactions + const isRealUser = await QuestService.isRealUser(quest.variant, { quest, account, data }); + if (!isRealUser.result) return res.json({ error: isRealUser.reason }); + + // Add wallet and add score for address + const { score, error } = await GitcoinService.getScoreUniqueHumanity( + quest.scorerId, + data.metadata.address.toLowerCase(), + ); + if (error) return res.json({ error }); + data.metadata['score'] = score; + + const { result, reason } = await QuestService.getValidationResult(quest.variant, { quest, account, data }); + if (!result) return res.json({ error: reason }); + + const job = await agenda.now(JobType.CreateQuestEntry, { + variant: QuestVariant.Gitcoin, + questId: String(quest._id), + sub: account.sub, + data, + }); + + res.json({ jobId: job.attrs._id }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/quests/gitcoin/gitcoin.router.ts b/apps/api/src/app/controllers/quests/gitcoin/gitcoin.router.ts new file mode 100644 index 000000000..21d2ab6c9 --- /dev/null +++ b/apps/api/src/app/controllers/quests/gitcoin/gitcoin.router.ts @@ -0,0 +1,16 @@ +import express from 'express'; +import * as CreateEntry from './entries/post.controller'; +import { assertRequestInput, assertAccount } from '@thxnetwork/api/middlewares'; +import { limitInSeconds } from '@thxnetwork/api/util/ratelimiter'; + +export const router: express.Router = express.Router({ mergeParams: true }); + +router.post( + '/:id/entries', + limitInSeconds(3), + assertRequestInput(CreateEntry.validation), + assertAccount, + CreateEntry.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/quests/list.controller.ts b/apps/api/src/app/controllers/quests/list.controller.ts new file mode 100644 index 000000000..82869975b --- /dev/null +++ b/apps/api/src/app/controllers/quests/list.controller.ts @@ -0,0 +1,38 @@ +import { Request, Response } from 'express'; +import { getIP } from '@thxnetwork/api/util/ip'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import QuestService from '@thxnetwork/api/services/QuestService'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import { parseToken } from '@thxnetwork/api/util/jwt'; + +// This endpoint is public so we do not get req.auth populated +// so we need to decode the auth header manually when it is present +const controller = async (req: Request, res: Response) => { + const token = parseToken(req.header('authorization')); + const sub = token && token.sub; + + const pool = await PoolService.getById(req.header('X-PoolId')); + const account = sub && (await AccountProxy.findById(sub)); + + const ip = getIP(req); + // Results are returned in order of the QuestVariant enum keys + const [daily, invite, twitter, discord, youtube, custom, web3, gitcoin, webhook] = await QuestService.list({ + pool, + account, + data: { ip }, + }); + + res.json({ + daily, + custom, + invite, + twitter, + discord, + youtube, + web3, + gitcoin, + webhook, + }); +}; + +export { controller }; diff --git a/apps/api/src/app/controllers/quests/quests.router.ts b/apps/api/src/app/controllers/quests/quests.router.ts new file mode 100644 index 000000000..8d3a44425 --- /dev/null +++ b/apps/api/src/app/controllers/quests/quests.router.ts @@ -0,0 +1,24 @@ +import { checkJwt, corsHandler } from '@thxnetwork/api/middlewares'; +import express from 'express'; +import * as ListQuests from './list.controller'; +import * as ListQuestsPublic from './recent/list.controller'; +import RouterQuestSocial from './social/social.router'; +import RouterQuestWeb3 from './web3/web3.router'; +import RouterQuestGitcoin from './gitcoin/gitcoin.router'; +import RouterQuestDaily from './daily/daily.router'; +import RouterQuestCustom from './custom/custom.router'; +import RouterQuestWebhook from './webhook/webhook.router'; + +const router: express.Router = express.Router(); + +router.get('/', ListQuests.controller); +router.get('/public', ListQuestsPublic.controller); +router.use(checkJwt).use(corsHandler); +router.use('/social', RouterQuestSocial); +router.use('/web3', RouterQuestWeb3); +router.use('/gitcoin', RouterQuestGitcoin); +router.use('/daily', RouterQuestDaily); +router.use('/custom', RouterQuestCustom); +router.use('/webhook', RouterQuestWebhook); + +export default router; diff --git a/apps/api/src/app/controllers/quests/recent/list.controller.ts b/apps/api/src/app/controllers/quests/recent/list.controller.ts new file mode 100644 index 000000000..9eba84e02 --- /dev/null +++ b/apps/api/src/app/controllers/quests/recent/list.controller.ts @@ -0,0 +1,99 @@ +import { Request, Response } from 'express'; +import { query } from 'express-validator'; +import { + Pool, + QuestDaily, + QuestInvite, + QuestSocial, + QuestCustom, + QuestWeb3, + QuestGitcoin, +} from '@thxnetwork/api/models'; +const validation = [query('page').isInt(), query('limit').isInt(), query('search').optional().isString()]; + +const controller = async (req: Request, res: Response) => { + const questModels = [QuestDaily, QuestInvite, QuestSocial, QuestCustom, QuestWeb3, QuestGitcoin]; + const questLookupStages = questModels.map((model) => { + return { + $lookup: { + from: model.collection.name, + localField: 'poolId', + foreignField: 'poolId', + as: model.collection.name, + }, + }; + }); + + const decoratedPools = await Pool.aggregate([ + { + $addFields: { + poolId: { + $convert: { + input: '$_id', + to: 'string', + }, + }, + }, + }, + ...questLookupStages, + { + $lookup: { + from: 'widget', + localField: 'poolId', + foreignField: 'poolId', + as: 'widget', + }, + }, + { + $lookup: { + from: 'brand', + localField: 'poolId', + foreignField: 'poolId', + as: 'brand', + }, + }, + { + $match: { + 'settings.isPublished': true, + 'rank': { $exists: true }, + }, + }, + ]).exec(); + + const sortByDate = (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + + const result = decoratedPools + // Format and sort all quests per pool + .map((result) => { + const mapper = (q) => ({ + ...q, + amount: q.amounts ? q.amounts[q.amounts.length - 1] : q.amount, + widget: result.widget && result.widget[0], + domain: result.widget && result.widget[0] && result.widget[0].domain, + brand: result.brand && result.brand[0], + }); + + const quests = [ + ...result[QuestDaily.collection.name].map(mapper).sort(sortByDate), + ...result[QuestInvite.collection.name].map(mapper).sort(sortByDate), + ...result[QuestSocial.collection.name].map(mapper).sort(sortByDate), + ...result[QuestCustom.collection.name].map(mapper).sort(sortByDate), + ...result[QuestWeb3.collection.name].map(mapper).sort(sortByDate), + ...result[QuestGitcoin.collection.name].map(mapper).sort(sortByDate), + ]; + + return { + quests, + }; + }) + // Last quest per pool + .map((pool) => pool.quests[0]) + // Sort by createdAt + .sort(sortByDate) + // Cut of first 4 results + .slice(0, 4); + + res.json(result); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/quests/social/entries/post.controller.ts b/apps/api/src/app/controllers/quests/social/entries/post.controller.ts new file mode 100644 index 000000000..6438ae9f2 --- /dev/null +++ b/apps/api/src/app/controllers/quests/social/entries/post.controller.ts @@ -0,0 +1,68 @@ +import { Request, Response } from 'express'; +import { body, param } from 'express-validator'; +import { JobType, agenda } from '@thxnetwork/api/util/agenda'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { QuestVariant } from '@thxnetwork/common/enums'; +import { TwitterUser } from '@thxnetwork/api/models/TwitterUser'; +import { DiscordMessage, DiscordReaction, QuestSocial } from '@thxnetwork/api/models'; +import QuestService from '@thxnetwork/api/services/QuestService'; +import DiscordService from '@thxnetwork/api/services/DiscordService'; +import { QuestSocialRequirement } from '@thxnetwork/common/enums'; + +const validation = [param('id').isMongoId(), body('recaptcha').isString()]; + +const controller = async ({ params, body, account }: Request, res: Response) => { + // Get the quest document + const quest = await QuestSocial.findById(params.id); + if (!quest) throw new NotFoundError('Quest not found'); + + // Get platform user id for account + const platformUserId = QuestService.findUserIdForInteraction(account, quest.interaction); + if (!platformUserId) return res.json({ error: 'Could not find platform user id.' }); + + const data = { metadata: { platformUserId, discord: {}, twitter: {} }, recaptcha: body.recaptcha }; + + // Running separately to avoid issues when getting validation results from Discord interactions + const isRealUser = await QuestService.isRealUser(quest.variant, { quest, account, data }); + if (!isRealUser.result) return res.json({ error: isRealUser.reason }); + + // Get validation result for this quest entry + const { result, reason } = await QuestService.getValidationResult(quest.variant, { quest, account, data }); + if (!result) return res.json({ error: reason }); + + // For Discord Bot quests we store server user name in metadata + if (quest.variant === QuestVariant.Discord && quest.interaction !== QuestSocialRequirement.DiscordGuildJoined) { + const guild = await DiscordService.getGuild(quest.poolId); + const member = guild && (await DiscordService.getMember(guild.id, platformUserId)); + + data.metadata.discord = { + guildId: guild && guild.id, + username: member.user.username, + joinedAt: new Date(member.joinedTimestamp).toISOString(), + reactionCount: guild + ? await DiscordReaction.countDocuments({ guildId: guild.id, userId: platformUserId }) + : 0, + messageCount: guild + ? await DiscordMessage.countDocuments({ guildId: guild.id, userId: platformUserId }) + : 0, + }; + } + + // For Twitter quests we store public metrics in metadata + if (quest.variant === QuestVariant.Twitter) { + const user = await TwitterUser.findOne({ userId: platformUserId }); + data.metadata.twitter = user.publicMetrics; + } + + // Schedule serial job + const job = await agenda.now(JobType.CreateQuestEntry, { + variant: quest.variant, + questId: String(quest._id), + sub: account.sub, + data, + }); + + res.json({ jobId: job.attrs._id }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/quests/social/social.router.ts b/apps/api/src/app/controllers/quests/social/social.router.ts new file mode 100644 index 000000000..6f38db8d4 --- /dev/null +++ b/apps/api/src/app/controllers/quests/social/social.router.ts @@ -0,0 +1,10 @@ +import express from 'express'; +import { assertRequestInput, assertAccount } from '@thxnetwork/api/middlewares'; +import { limitInSeconds } from '@thxnetwork/api/util/ratelimiter'; +import * as Create from './entries/post.controller'; + +export const router: express.Router = express.Router({ mergeParams: true }); + +router.post('/:id/entries', limitInSeconds(3), assertRequestInput(Create.validation), assertAccount, Create.controller); + +export default router; diff --git a/apps/api/src/app/controllers/quests/web3/entries/post.controller.ts b/apps/api/src/app/controllers/quests/web3/entries/post.controller.ts new file mode 100644 index 000000000..6ba050221 --- /dev/null +++ b/apps/api/src/app/controllers/quests/web3/entries/post.controller.ts @@ -0,0 +1,59 @@ +import { Request, Response } from 'express'; +import { body, param } from 'express-validator'; +import { QuestWeb3 } from '@thxnetwork/api/models/QuestWeb3'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { agenda } from '@thxnetwork/api/util/agenda'; +import { recoverSigner } from '@thxnetwork/api/util/network'; +import { chainList } from '@thxnetwork/common/chains'; +import { JobType, QuestVariant } from '@thxnetwork/common/enums'; +import QuestWeb3Service from '@thxnetwork/api/services/QuestWeb3Service'; +import QuestService from '@thxnetwork/api/services/QuestService'; + +const validation = [ + param('id').isMongoId(), + body('signature').isString(), + body('chainId').isInt(), + body('recaptcha').isString(), +]; + +const controller = async ({ account, body, params }: Request, res: Response) => { + const quest = await QuestWeb3.findById(params.id); + if (!quest) throw new NotFoundError('Quest not found'); + + const address = recoverSigner(body.message, body.signature); + if (!address) throw new NotFoundError(`Could not recover address from signature.`); + + const { rpc, name } = chainList[body.chainId]; + if (!rpc) throw new NotFoundError(`Could not find RPC for ${name}`); + + const data = { recaptcha: body.recaptcha, metadata: { address, rpc, chainId: body.chainId, callResult: '' } }; + + // Running separately to avoid issues when getting validation results from Discord interactions + const isRealUser = await QuestService.isRealUser(quest.variant, { quest, account, data }); + if (!isRealUser.result) return res.json({ error: isRealUser.reason }); + + // Fetch the call result so we can store it in the entry + const callResult = await QuestWeb3Service.getCallResult({ quest, account, data }); + if (!callResult.result) return res.json({ error: callResult.reason }); + data.metadata.callResult = callResult.value.toString(); + + // Validate the result + const validationResult = await QuestService.getValidationResult(quest.variant, { + quest, + account, + data, + }); + if (!validationResult.result) return res.json({ error: validationResult.reason }); + + // Schedule the job + const job = await agenda.now(JobType.CreateQuestEntry, { + variant: QuestVariant.Web3, + questId: String(quest._id), + sub: account.sub, + data, + }); + + res.json({ jobId: job.attrs._id }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/quests/web3/web3.router.ts b/apps/api/src/app/controllers/quests/web3/web3.router.ts new file mode 100644 index 000000000..82a49522a --- /dev/null +++ b/apps/api/src/app/controllers/quests/web3/web3.router.ts @@ -0,0 +1,10 @@ +import express from 'express'; +import * as Create from './entries/post.controller'; +import { assertAccount, assertRequestInput } from '@thxnetwork/api/middlewares'; +import { limitInSeconds } from '@thxnetwork/api/util/ratelimiter'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.post('/:id/entries', limitInSeconds(3), assertRequestInput(Create.validation), assertAccount, Create.controller); + +export default router; diff --git a/apps/api/src/app/controllers/quests/webhook/entries/post.controller.ts b/apps/api/src/app/controllers/quests/webhook/entries/post.controller.ts new file mode 100644 index 000000000..d0b6cf6ba --- /dev/null +++ b/apps/api/src/app/controllers/quests/webhook/entries/post.controller.ts @@ -0,0 +1,42 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { agenda } from '@thxnetwork/api/util/agenda'; +import { JobType, QuestVariant } from '@thxnetwork/common/enums'; +import { QuestWebhook } from '@thxnetwork/api/models'; +import QuestService from '@thxnetwork/api/services/QuestService'; + +const validation = [param('id').isMongoId()]; + +const controller = async ({ account, body, params }: Request, res: Response) => { + const quest = await QuestWebhook.findById(params.id); + if (!quest) throw new NotFoundError('Quest not found'); + + const data = { + recaptcha: body.recaptcha, + }; + + // Running separately to avoid issues when getting validation results from Discord interactions + const isRealUser = await QuestService.isRealUser(quest.variant, { quest, account, data }); + if (!isRealUser.result) return res.json({ error: isRealUser.reason }); + + // Validate the result + const validationResult = await QuestService.getValidationResult(quest.variant, { + quest, + account, + data, + }); + if (!validationResult.result) return res.json({ error: validationResult.reason }); + + // Schedule the job + const job = await agenda.now(JobType.CreateQuestEntry, { + variant: QuestVariant.Webhook, + questId: String(quest._id), + sub: account.sub, + data: { ...data, metadata: validationResult }, + }); + + res.json({ jobId: job.attrs._id }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/quests/webhook/webhook.router.ts b/apps/api/src/app/controllers/quests/webhook/webhook.router.ts new file mode 100644 index 000000000..8ebf44eae --- /dev/null +++ b/apps/api/src/app/controllers/quests/webhook/webhook.router.ts @@ -0,0 +1,16 @@ +import express from 'express'; +import { assertAccount, assertRequestInput } from '@thxnetwork/api/middlewares'; +import { limitInSeconds } from '@thxnetwork/api/util/ratelimiter'; +import * as CreateEntry from './entries/post.controller'; + +export const router: express.Router = express.Router({ mergeParams: true }); + +router.post( + '/:id/entries', + limitInSeconds(3), + assertRequestInput(CreateEntry.validation), + assertAccount, + CreateEntry.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/rewards/list.controller.ts b/apps/api/src/app/controllers/rewards/list.controller.ts new file mode 100644 index 000000000..a8815d7ab --- /dev/null +++ b/apps/api/src/app/controllers/rewards/list.controller.ts @@ -0,0 +1,22 @@ +import { Request, Response } from 'express'; +import { parseToken } from '@thxnetwork/api/util/jwt'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import RewardService from '@thxnetwork/api/services/RewardService'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; + +const controller = async (req: Request, res: Response) => { + const token = parseToken(req.header('authorization')); + const sub = token && token.sub; + + const pool = await PoolService.getById(req.header('X-PoolId')); + const account = sub && (await AccountProxy.findById(sub)); + + const [coin, nft, custom, coupon, discordRole, galachain] = await RewardService.list({ + pool, + account, + }); + + res.json({ coin, nft, custom, coupon, discordRole, galachain }); +}; + +export { controller }; diff --git a/apps/api/src/app/controllers/rewards/payments/list.controller.ts b/apps/api/src/app/controllers/rewards/payments/list.controller.ts new file mode 100644 index 000000000..ce90a1944 --- /dev/null +++ b/apps/api/src/app/controllers/rewards/payments/list.controller.ts @@ -0,0 +1,12 @@ +import RewardService from '@thxnetwork/api/services/RewardService'; +import { Request, Response } from 'express'; +import { query } from 'express-validator'; + +const validation = [query('walletId').optional().isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const payments = await RewardService.findPaymentsForSub(req.auth.sub); + res.json(payments); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/rewards/payments/post.controller.ts b/apps/api/src/app/controllers/rewards/payments/post.controller.ts new file mode 100644 index 000000000..13957387b --- /dev/null +++ b/apps/api/src/app/controllers/rewards/payments/post.controller.ts @@ -0,0 +1,47 @@ +import { Request, Response } from 'express'; +import { body, param } from 'express-validator'; +import { ForbiddenError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { JobType, RewardVariant } from '@thxnetwork/common/enums'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import RewardService from '@thxnetwork/api/services/RewardService'; +import SafeService from '@thxnetwork/api/services/SafeService'; +import { agenda } from '@thxnetwork/api/util/agenda'; +import { Wallet } from '@thxnetwork/api/models'; + +const validation = [param('variant').isInt(), param('rewardId').isMongoId(), body('walletId').optional().isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const variant = req.params.variant as unknown as RewardVariant; + const rewardId = req.params.rewardId as string; + + const reward = await RewardService.findById(variant, rewardId); + if (!reward) throw new NotFoundError('Reward not found'); + + const pool = await PoolService.getById(reward.poolId); + if (!pool) throw new NotFoundError('Campaign not found'); + + const safe = await SafeService.findOneByPool(pool); + if (!safe) throw new NotFoundError('Campaign Safe not found'); + + const account = await AccountProxy.findById(req.auth.sub); + if (!account) throw new NotFoundError('Account not found'); + + const wallet = req.body.walletId ? await Wallet.findById(req.body.walletId) : null; + const validationResult = await RewardService.getValidationResult({ reward, account, safe, wallet }); + if (!validationResult.result) { + throw new ForbiddenError(validationResult.reason); + } + + // Serialize payment processing with job queue + const job = await agenda.now(JobType.CreateRewardPayment, { + variant, + rewardId, + sub: account.sub, + walletId: req.body.walletId, + }); + + res.json({ jobId: job.attrs._id }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/rewards/rewards.router.ts b/apps/api/src/app/controllers/rewards/rewards.router.ts new file mode 100644 index 000000000..059aeb181 --- /dev/null +++ b/apps/api/src/app/controllers/rewards/rewards.router.ts @@ -0,0 +1,18 @@ +import { assertRequestInput, checkJwt, corsHandler } from '@thxnetwork/api/middlewares'; +import express, { Router } from 'express'; +import * as ListRewards from './list.controller'; +import * as CreateRewardPayment from './payments/post.controller'; +import * as ListRewardPayment from './payments/list.controller'; + +const router: express.Router = express.Router({ mergeParams: true }); + +router.get('/', ListRewards.controller); +router.use(checkJwt, corsHandler); +router.post( + '/:variant/:rewardId/payments', + assertRequestInput(CreateRewardPayment.validation), + CreateRewardPayment.controller, +); +router.get('/payments', assertRequestInput(ListRewardPayment.validation), ListRewardPayment.controller); + +export default router; diff --git a/apps/api/src/app/controllers/token/cs/get.controller.ts b/apps/api/src/app/controllers/token/cs/get.controller.ts new file mode 100644 index 000000000..fc8a3444c --- /dev/null +++ b/apps/api/src/app/controllers/token/cs/get.controller.ts @@ -0,0 +1,8 @@ +import { Request, Response } from 'express'; +import { CIRCULATING_SUPPLY } from '@thxnetwork/api/config/secrets'; + +const controller = async (req: Request, res: Response) => { + res.header('Content-Type', 'text/plain').send(CIRCULATING_SUPPLY); +}; + +export { controller }; diff --git a/apps/api/src/app/controllers/token/token.router.ts b/apps/api/src/app/controllers/token/token.router.ts new file mode 100644 index 000000000..0865ea18b --- /dev/null +++ b/apps/api/src/app/controllers/token/token.router.ts @@ -0,0 +1,11 @@ +import express, { Router } from 'express'; + +import * as ReadTokenCirculatingSupply from './cs/get.controller'; +import * as ReadTokenTotalSupply from './ts/get.controller'; + +const router: express.Router = express.Router(); + +router.get('/cs', ReadTokenCirculatingSupply.controller); +router.get('/ts', ReadTokenTotalSupply.controller); + +export default router; diff --git a/apps/api/src/app/controllers/token/ts/get.controller.ts b/apps/api/src/app/controllers/token/ts/get.controller.ts new file mode 100644 index 000000000..c34b4eb73 --- /dev/null +++ b/apps/api/src/app/controllers/token/ts/get.controller.ts @@ -0,0 +1,8 @@ +import { Request, Response } from 'express'; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['THX Token'] + res.header('Content-Type', 'text/plain').send('100000000'); +}; + +export { controller }; diff --git a/apps/api/src/app/controllers/transactions/list.controller.ts b/apps/api/src/app/controllers/transactions/list.controller.ts new file mode 100644 index 000000000..f6f9deaf0 --- /dev/null +++ b/apps/api/src/app/controllers/transactions/list.controller.ts @@ -0,0 +1,21 @@ +import { Request, Response } from 'express'; +import { query } from 'express-validator'; +import { Wallet } from '@thxnetwork/api/models'; +import { ChainId } from '@thxnetwork/common/enums'; +import { NODE_ENV } from '@thxnetwork/api/config/secrets'; + +const validation = [query('chainId').optional().isNumeric(), query('poolId').optional().isString()]; + +const controller = async (req: Request, res: Response) => { + const chainId = NODE_ENV === 'production' ? ChainId.Polygon : ChainId.Hardhat; + const wallets = await Wallet.find({ + sub: req.auth.sub, + chainId, + safeVersion: { $exists: true }, + poolId: req.query.poolId, + }); + + res.json(wallets); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/upload/put.controller.ts b/apps/api/src/app/controllers/upload/put.controller.ts new file mode 100644 index 000000000..25315103c --- /dev/null +++ b/apps/api/src/app/controllers/upload/put.controller.ts @@ -0,0 +1,10 @@ +import { Request, Response } from 'express'; +import ImageService from '@thxnetwork/api/services/ImageService'; + +const controller = async (req: Request, res: Response) => { + if (!req.file) return res.status(440).send('There no file to process'); + const publicUrl = await ImageService.upload(req.file); + res.send({ publicUrl }); +}; + +export { controller }; diff --git a/apps/api/src/app/controllers/upload/upload.router.ts b/apps/api/src/app/controllers/upload/upload.router.ts new file mode 100644 index 000000000..e513f42d5 --- /dev/null +++ b/apps/api/src/app/controllers/upload/upload.router.ts @@ -0,0 +1,9 @@ +import express, { Router } from 'express'; +import { upload } from '@thxnetwork/api/util/multer'; +import * as PutUpload from './put.controller'; + +const router: express.Router = express.Router(); + +router.put('/', upload.single('file'), PutUpload.controller); + +export default router; diff --git a/apps/api/src/app/controllers/ve/claim/post.controller.ts b/apps/api/src/app/controllers/ve/claim/post.controller.ts new file mode 100644 index 000000000..6e95be582 --- /dev/null +++ b/apps/api/src/app/controllers/ve/claim/post.controller.ts @@ -0,0 +1,14 @@ +import { Request, Response } from 'express'; +import { query } from 'express-validator'; +import VoteEscrowService from '@thxnetwork/api/services/VoteEscrowService'; + +const validation = [query('walletId').isMongoId()]; + +const controller = async ({ wallet }: Request, res: Response) => { + // TODO Check if wallet has tokens to claim + // Propose the claimTokens transaction + const txs = await VoteEscrowService.claimTokens(wallet); + + res.status(201).json(txs); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/ve/deposit/post.controller.ts b/apps/api/src/app/controllers/ve/deposit/post.controller.ts new file mode 100644 index 000000000..b24f96521 --- /dev/null +++ b/apps/api/src/app/controllers/ve/deposit/post.controller.ts @@ -0,0 +1,35 @@ +import { Request, Response } from 'express'; +import { body, query } from 'express-validator'; +import { ForbiddenError } from '@thxnetwork/api/util/errors'; +import { BigNumber } from 'ethers'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { contractNetworks } from '@thxnetwork/api/contracts'; +import VoteEscrowService from '@thxnetwork/api/services/VoteEscrowService'; + +const validation = [body('amountInWei').isString(), body('lockEndTimestamp').isInt(), query('walletId').isMongoId()]; + +const controller = async ({ body, wallet }: Request, res: Response) => { + // Check sufficient BPTGauge approval + const amount = await VoteEscrowService.getAllowance( + wallet, + contractNetworks[wallet.chainId].BPTGauge, + contractNetworks[wallet.chainId].VotingEscrow, + ); + if (BigNumber.from(amount).lt(body.amountInWei)) throw new ForbiddenError('Insufficient allowance'); + + // Check lockEndTimestamp to be more than today + 3 months + const { web3 } = getProvider(); + const latest = await web3.eth.getBlockNumber(); + const now = (await web3.eth.getBlock(latest)).timestamp; + if (now > body.lockEndTimestamp) throw new ForbiddenError('lockEndTimestamp needs be larger than today'); + + // Check SmartWalletWhitelist + const isApproved = await VoteEscrowService.isApprovedAddress(wallet.address, wallet.chainId); + if (!isApproved) throw new ForbiddenError('Wallet address is not on whitelist.'); + + // Deposit funds for wallet + const tx = await VoteEscrowService.deposit(wallet, body.amountInWei, body.lockEndTimestamp); + + res.status(201).json([tx]); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/ve/increase/post.controller.ts b/apps/api/src/app/controllers/ve/increase/post.controller.ts new file mode 100644 index 000000000..bbfcb12b9 --- /dev/null +++ b/apps/api/src/app/controllers/ve/increase/post.controller.ts @@ -0,0 +1,56 @@ +import { Request, Response } from 'express'; +import { body, query } from 'express-validator'; +import { ForbiddenError } from '@thxnetwork/api/util/errors'; +import { BigNumber } from 'ethers'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { contractNetworks } from '@thxnetwork/api/contracts'; +import VoteEscrowService from '@thxnetwork/api/services/VoteEscrowService'; + +const validation = [ + query('walletId').isMongoId(), + body('amountInWei').optional().isString(), + body('lockEndTimestamp').optional().isInt(), +]; + +const controller = async ({ wallet, body }: Request, res: Response) => { + // Check SmartWalletWhitelist + const isApproved = await VoteEscrowService.isApprovedAddress(wallet.address, wallet.chainId); + if (!isApproved) throw new ForbiddenError('Wallet address is not on whitelist.'); + + const txList = []; + if (body.amountInWei) { + // Check sufficient BPTGauge approval + const amount = await VoteEscrowService.getAllowance( + wallet, + contractNetworks[wallet.chainId].BPTGauge, + contractNetworks[wallet.chainId].VotingEscrow, + ); + if (BigNumber.from(amount).lt(body.amountInWei)) throw new ForbiddenError('Insufficient allowance'); + + // TODO Check sufficient balance + const txs = await VoteEscrowService.increaseAmount(wallet, body.amountInWei); + txList.push(txs); + } + + if (body.lockEndTimestamp) { + // Check lockEndTimestamp to be more than today + 90 days + const { web3 } = getProvider(); + const latest = await web3.eth.getBlockNumber(); + const now = (await web3.eth.getBlock(latest)).timestamp; + if (body.lockEndTimestamp < now) { + throw new ForbiddenError('lockEndTimestamp needs be larger than today'); + } + + // Check if lockEndTimestamp is more than current lock end + const lock = await VoteEscrowService.list(wallet); + if (body.lockEndTimestamp < Number(lock.end)) { + throw new ForbiddenError('lockEndTimestamp needs be larger than current lock end'); + } + + const txs = await VoteEscrowService.increaseUnlockTime(wallet, body.lockEndTimestamp); + txList.push(txs); + } + + res.status(201).json(txList); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/ve/list.controller.ts b/apps/api/src/app/controllers/ve/list.controller.ts new file mode 100644 index 000000000..dee351074 --- /dev/null +++ b/apps/api/src/app/controllers/ve/list.controller.ts @@ -0,0 +1,34 @@ +import { Request, Response } from 'express'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { contractArtifacts, contractNetworks } from '@thxnetwork/api/contracts'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { query } from 'express-validator'; +import WalletService from '@thxnetwork/api/services/WalletService'; +import VoteEscrowService from '@thxnetwork/api/services/VoteEscrowService'; +const parseMs = (s) => Number(s) * 1000; + +const validation = [query('walletId').isMongoId()]; +const controller = async (req: Request, res: Response) => { + const walletId = req.query.walletId as string; + const wallet = await WalletService.findById(walletId); + if (!wallet) throw new NotFoundError('Wallet not found.'); + + const { web3 } = getProvider(wallet.chainId); + const ve = new web3.eth.Contract( + contractArtifacts['VotingEscrow'].abi, + contractNetworks[wallet.chainId].VotingEscrow, + ); + + // Check for lock and determine ve fn to call + // Get veTHX balance and pending rewards + const [{ amount, end }, latestBlock, balance, rewards] = await Promise.all([ + VoteEscrowService.list(wallet), + web3.eth.getBlock('latest'), + ve.methods.balanceOf(wallet.address).call(), + VoteEscrowService.listRewards(wallet), + ]); + + res.json([{ balance, amount, end: parseMs(end), now: parseMs(latestBlock.timestamp), rewards }]); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/ve/ve.router.ts b/apps/api/src/app/controllers/ve/ve.router.ts new file mode 100644 index 000000000..ad777bcc1 --- /dev/null +++ b/apps/api/src/app/controllers/ve/ve.router.ts @@ -0,0 +1,20 @@ +import express, { Router } from 'express'; +import { assertRequestInput } from '@thxnetwork/api/middlewares'; +import { assertWallet } from '@thxnetwork/api/middlewares/assertWallet'; + +import * as ListController from './list.controller'; +import * as CreateVEDeposit from './deposit/post.controller'; +import * as CreateVEIncrease from './increase/post.controller'; +import * as CreateVEClaim from './claim/post.controller'; +import * as CreateVEWithdraw from './withdraw/post.controller'; + +const router: express.Router = express.Router(); + +router.use('/', assertWallet); +router.get('/', assertRequestInput(ListController.validation), ListController.controller); +router.post('/deposit', assertRequestInput(CreateVEDeposit.validation), CreateVEDeposit.controller); +router.post('/increase', assertRequestInput(CreateVEIncrease.validation), CreateVEIncrease.controller); +router.post('/claim', assertRequestInput(CreateVEClaim.validation), CreateVEClaim.controller); +router.post('/withdraw', assertRequestInput(CreateVEWithdraw.validation), CreateVEWithdraw.controller); + +export default router; diff --git a/apps/api/src/app/controllers/ve/ve.test.ts b/apps/api/src/app/controllers/ve/ve.test.ts new file mode 100644 index 000000000..4be45c465 --- /dev/null +++ b/apps/api/src/app/controllers/ve/ve.test.ts @@ -0,0 +1,332 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { BigNumber, Contract, ethers } from 'ethers'; +import { ChainId } from '@thxnetwork/common/enums'; +import { contractArtifacts, contractNetworks } from '@thxnetwork/api/contracts'; +import { sub, userWalletPrivateKey, widgetAccessToken } from '@thxnetwork/api/util/jest/constants'; +import { WalletDocument } from '@thxnetwork/api/models/Wallet'; +import { signTxHash, timeTravel } from '@thxnetwork/api/util/jest/network'; +import { poll } from '@thxnetwork/api/util/polling'; +import SafeService from '@thxnetwork/api/services/SafeService'; + +const user = request.agent(app); +const { signer } = getProvider(ChainId.Hardhat); + +describe('VESytem', () => { + beforeAll(beforeAllCallback); + afterAll(afterAllCallback); + + const amountInWei = String(ethers.utils.parseUnits('1000', 'ether')); + const chainId = ChainId.Hardhat; + + let safeWallet!: WalletDocument, + testBPT!: Contract, + testBPTGauge!: Contract, + testBAL!: Contract, + vethx!: Contract, + rdthx!: Contract, + rfthx!: Contract, + scthx!: Contract; + + it('Deploy Tokens', async () => { + safeWallet = await SafeService.findOne({ sub, poolId: { $exists: false }, safeVersion: { $exists: true } }); + expect(safeWallet.address).toBeDefined(); + + testBAL = new ethers.Contract(contractNetworks[chainId].BAL, contractArtifacts['BAL'].abi, signer); + testBPT = new ethers.Contract(contractNetworks[chainId].BPT, contractArtifacts['BPT'].abi, signer); + testBPTGauge = new ethers.Contract( + contractNetworks[chainId].BPTGauge, + contractArtifacts['BPTGauge'].abi, + signer, + ); + + vethx = new ethers.Contract( + contractNetworks[chainId].VotingEscrow, + contractArtifacts['VotingEscrow'].abi, + signer, + ); + rdthx = new ethers.Contract( + contractNetworks[chainId].RewardDistributor, + contractArtifacts['RewardDistributor'].abi, + signer, + ); + rfthx = new ethers.Contract( + contractNetworks[chainId].RewardFaucet, + contractArtifacts['RewardFaucet'].abi, + signer, + ); + scthx = new ethers.Contract( + contractNetworks[chainId].SmartWalletWhitelist, + contractArtifacts['SmartWalletWhitelist'].abi, + signer, + ); + }); + + describe('Create Reward Distribution', () => { + it('Create Reward Distribution after first week', async () => { + const amountBPT = String(ethers.utils.parseUnits('100000', 'ether')); + const amountBAL = String(ethers.utils.parseUnits('1000', 'ether')); + + // Travel past first week else this throws "Reward distribution has not started yet" + await timeTravel(60 * 60 * 24 * 7); + + // Deposit reward tokens into rdthx + await testBPT.approve(rfthx.address, amountBPT); + await testBAL.approve(rfthx.address, amountBAL); + await rfthx.depositEqualWeeksPeriod(testBPT.address, amountBPT, '4'); + await rfthx.depositEqualWeeksPeriod(testBAL.address, amountBAL, '4'); + }); + }); + + describe('Stake BPT ', () => { + it('Balance = total', async () => { + let tx = await testBPT.transfer(safeWallet.address, amountInWei); + tx = await tx.wait(); + const event = tx.events.find((ev) => ev.event === 'Transfer'); + expect(event).toBeDefined(); + const balanceInWei = await testBPT.balanceOf(safeWallet.address); + expect(balanceInWei.gt(0)).toBe(true); + }); + it('Approve', async () => { + const { status, body } = await user + .post('/v1/erc20/allowance') + .set({ Authorization: widgetAccessToken }) + .query({ walletId: String(safeWallet._id) }) + .send({ tokenAddress: testBPT.address, amountInWei, spender: testBPTGauge.address }); + expect(status).toBe(201); + + for (const tx of body) { + expect(tx.safeTxHash).toBeDefined(); + + const { signature } = await signTxHash(safeWallet.address, tx.safeTxHash, userWalletPrivateKey); + await user + .post('/v1/account/wallets/confirm') + .set({ Authorization: widgetAccessToken }) + .query({ walletId: String(safeWallet._id) }) + .send({ chainId: ChainId.Hardhat, safeTxHash: tx.safeTxHash, signature }) + .expect(200); + } + }); + + it('Wait for approved amount', async () => { + // Replace with API call + await poll( + () => testBPT.allowance(safeWallet.address, testBPTGauge.address), + (result: BigNumber) => result.eq(0), + 1000, + ); + }); + + it('Stake 1000 BPT', async () => { + const { status, body } = await user + .post('/v1/liquidity/stake') + .set({ Authorization: widgetAccessToken }) + .query({ walletId: String(safeWallet._id) }) + .send({ amountInWei }); + expect(status).toBe(201); + for (const tx of body) { + expect(tx.safeTxHash).toBeDefined(); + + const { signature } = await signTxHash(safeWallet.address, tx.safeTxHash, userWalletPrivateKey); + await user + .post('/v1/account/wallets/confirm') + .set({ Authorization: widgetAccessToken }) + .query({ walletId: String(safeWallet._id) }) + .send({ chainId: ChainId.Hardhat, safeTxHash: tx.safeTxHash, signature }) + .expect(200); + } + }); + + it('User received BPT-gauge', async () => { + await poll( + () => testBPTGauge.balanceOf(safeWallet.address), + (amount: BigNumber) => BigNumber.from(amount).eq(0), + 1000, + ); + + const balanceInWei = await testBPTGauge.balanceOf(safeWallet.address); + expect(balanceInWei.gt(0)).toBe(true); + }); + }); + + describe('Lock BPT-gauge ', () => { + it('WhiteList Safe Wallet', async () => { + // Move this to step 1 in VE UI modals + let tx = await scthx.approveWallet(safeWallet.address); + tx = await tx.wait(); + const event = tx.events.find((ev) => ev.event === 'ApproveWallet'); + expect(event).toBeDefined(); + }); + + it('Approve', async () => { + const { status, body } = await user + .post('/v1/erc20/allowance') + .set({ Authorization: widgetAccessToken }) + .query({ walletId: String(safeWallet._id) }) + .send({ + tokenAddress: contractNetworks[chainId].BPTGauge, + amountInWei, + spender: contractNetworks[chainId].VotingEscrow, + }); + expect(status).toBe(201); + + for (const tx of body) { + expect(tx.safeTxHash).toBeDefined(); + + const { signature } = await signTxHash(safeWallet.address, tx.safeTxHash, userWalletPrivateKey); + await user + .post('/v1/account/wallets/confirm') + .set({ Authorization: widgetAccessToken }) + .query({ walletId: String(safeWallet._id) }) + .send({ chainId: ChainId.Hardhat, safeTxHash: tx.safeTxHash, signature }) + .expect(200); + } + }); + + it('Wait for approved amount', async () => { + // Replace with API call + await poll( + () => testBPTGauge.allowance(safeWallet.address, vethx.address), + (result: BigNumber) => result.eq(0), + 1000, + ); + }); + + it('Deposit 1000', async () => { + const lockEndTimestamp = Math.ceil(Date.now() / 1000) + 60 * 60 * 24 * 7 * 12; // 12 weeks from now + const { status, body } = await user + .post('/v1/ve/deposit') + .set({ Authorization: widgetAccessToken }) + .query({ walletId: String(safeWallet._id) }) + .send({ amountInWei, lockEndTimestamp }); + expect(status).toBe(201); + for (const tx of body) { + expect(tx.safeTxHash).toBeDefined(); + + const { signature } = await signTxHash(safeWallet.address, tx.safeTxHash, userWalletPrivateKey); + await user + .post('/v1/account/wallets/confirm') + .set({ Authorization: widgetAccessToken }) + .query({ walletId: String(safeWallet._id) }) + .send({ chainId: ChainId.Hardhat, safeTxHash: tx.safeTxHash, signature }) + .expect(200); + } + }); + + it('Balance = total - deposit', async () => { + await poll( + () => vethx.locked(safeWallet.address), + (result: { amount: BigNumber }) => BigNumber.from(result.amount).eq(0), + 1000, + ); + + const balanceInWei = await testBPTGauge.balanceOf(safeWallet.address); + const totalMinDeposit = BigNumber.from(amountInWei).sub(amountInWei); + + expect(balanceInWei.eq(totalMinDeposit)).toBe(true); + }); + + it('List locks ', async () => { + const { status, body } = await user + .get('/v1/ve') + .query({ walletId: String(safeWallet._id) }) + .set({ Authorization: widgetAccessToken }) + .send(); + + expect(Number(body[0].rewards)).toBe; + expect(Number(body[0].end)).toBeGreaterThan(Number(body[0].now)); + expect(body[0].amount).toBe(amountInWei); + expect(status).toBe(200); + }); + }); + + // describe('Claim THX incentives', () => { + // it('Claim Tokens (after 14 days)', async () => { + // console.log(await rfthx.getUpcomingRewardsForNWeeks(testBPT.address, 4)); + // console.log(await rfthx.getUpcomingRewardsForNWeeks(testBAL.address, 4)); + + // // Travel past end date of the first reward eligible week + // await timeTravel(60 * 60 * 24 * 8); + + // console.log(await rfthx.getUpcomingRewardsForNWeeks(testBPT.address, 4)); + // console.log(await rfthx.getUpcomingRewardsForNWeeks(testBAL.address, 4)); + + // const balance = await testBPT.balanceOf(safeWallet.address); + // expect(balance).toBeDefined(); + + // let tx = await rdthx.claimToken(safeWallet.address, testBPT.address); + // tx = await tx.wait(); + + // const event = tx.events.find((ev) => ev.event === 'TokenCheckpointed'); + // expect(event).toBeDefined(); + + // const balanceAfterClaim = await testBPT.balanceOf(safeWallet.address); + // expect(BigNumber.from(balance).lt(balanceAfterClaim)).toBe(true); + // }); + // }); + + describe('Withdraw BPT', () => { + it('Withdraw', async () => { + const { status, body } = await user + .post('/v1/ve/withdraw') + .set({ Authorization: widgetAccessToken }) + .query({ walletId: String(safeWallet._id) }) + .send({ isEarlyAttempt: false }); + expect(status).toBe(403); + expect(body.error.message).toBe('Funds are locked'); + }); + + it('Withdraw Early 1000 - penalty', async () => { + const { status, body } = await user + .post('/v1/ve/withdraw') + .set({ Authorization: widgetAccessToken }) + .query({ walletId: String(safeWallet._id) }) + .send({ isEarlyAttempt: true }); + expect(status).toBe(201); + for (const tx of body) { + expect(tx.safeTxHash).toBeDefined(); + + const { signature } = await signTxHash(safeWallet.address, tx.safeTxHash, userWalletPrivateKey); + await user + .post('/v1/account/wallets/confirm') + .set({ Authorization: widgetAccessToken }) + .query({ walletId: String(safeWallet._id) }) + .send({ chainId: ChainId.Hardhat, safeTxHash: tx.safeTxHash, signature }) + .expect(200); + } + }); + + // it('Balance = total + deposit - penalty', async () => { + // await poll( + // () => vethx.locked(safeWallet.address), + // (result: { amount: BigNumber }) => !BigNumber.from(result.amount).eq(0), + // 1000, + // ); + + // const balanceInWei = await testBPT.balanceOf(safeWallet.address); + // // const rdBalanceInWei = await testBPT.balanceOf(rdthx.address); + // // console.log( + // // String(balanceInWei), // 1024259542566872427984000 + // // String(rdBalanceInWei), // 25740457433127572016000 + // // String(balanceInWei.add(rdBalanceInWei)), // 1050000000000000000000000 + // // String(balanceInWei.add(rdBalanceInWei).sub(totalSupplyInWei)), // 50000000000000000000000 + // // ); + + // // Due to early exit expect less BPT to be returned and the balance of + // // the penalty treasury to increase. Formula to calculate the penalty is in + // // the VE contract. + // // Eg: + // // balanceInWei = 999917384259259259260000 + // // rdBalanceInWei = 82615740740740740000 + // // console.log(String(balanceInWei), amountInWei); + // // Should be larger due to reward claim + // // console.log(String(balanceInWei)); + // // console.log(String(amountInWei)); + // // console.log(String(BigNumber.from(balanceInWei).sub(BigNumber.from(amountInWei)))); + // console.log('balanceInWei', balanceInWei.toString()); + // expect(BigNumber.from(balanceInWei).gt(BigNumber.from(amountInWei))).toBe(true); + // }); + }); +}); diff --git a/apps/api/src/app/controllers/ve/withdraw/post.controller.ts b/apps/api/src/app/controllers/ve/withdraw/post.controller.ts new file mode 100644 index 000000000..ed2ca1e20 --- /dev/null +++ b/apps/api/src/app/controllers/ve/withdraw/post.controller.ts @@ -0,0 +1,34 @@ +import { Request, Response } from 'express'; +import { ForbiddenError } from '@thxnetwork/api/util/errors'; +import { contractArtifacts, contractNetworks } from '@thxnetwork/api/contracts'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { body, query } from 'express-validator'; +import VoteEscrowService from '@thxnetwork/api/services/VoteEscrowService'; + +const validation = [ + query('walletId').isMongoId(), + body('isEarlyAttempt') + .isBoolean() + .customSanitizer((val: string) => (val ? JSON.parse(val) : false)), +]; + +const controller = async ({ wallet, body }: Request, res: Response) => { + // Check sufficient BPT approval + const { web3 } = getProvider(); + const ve = new web3.eth.Contract( + contractArtifacts['VotingEscrow'].abi, + contractNetworks[wallet.chainId].VotingEscrow, + ); + const lock = await ve.methods.locked(wallet.address).call(); + const now = (await web3.eth.getBlock('latest')).timestamp; + + // Check if client requests early exit and end date has not past + const isEarlyWithdraw = Number(lock.end) > Number(now); + if (!body.isEarlyAttempt && isEarlyWithdraw) throw new ForbiddenError('Funds are locked'); + + // Propose the withdraw transaction + const txs = await VoteEscrowService.withdraw(wallet, isEarlyWithdraw); + + res.status(201).json(txs); +}; +export { controller, validation }; diff --git a/apps/api/src/app/controllers/webhook/daily/daily-quest-webhook.test.ts b/apps/api/src/app/controllers/webhook/daily/daily-quest-webhook.test.ts new file mode 100644 index 000000000..743c384c7 --- /dev/null +++ b/apps/api/src/app/controllers/webhook/daily/daily-quest-webhook.test.ts @@ -0,0 +1,95 @@ +import request from 'supertest'; +import app from '@thxnetwork/api/'; +import { QuestVariant } from '@thxnetwork/common/enums'; +import { account4, dashboardAccessToken, widgetAccessToken4 } from '@thxnetwork/api/util/jest/constants'; +import { isAddress } from 'web3-utils'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { QuestDailyDocument } from '@thxnetwork/api/models'; +import { poll } from '@thxnetwork/api/util/polling'; +import { Job } from '@thxnetwork/api/models/Job'; +import { v4 } from 'uuid'; + +const user = request.agent(app); + +describe('Daily Rewards WebHooks', () => { + let poolId: string, dailyReward: QuestDailyDocument; + const eventName = v4(); + + beforeAll(beforeAllCallback); + afterAll(afterAllCallback); + + it('POST /pools', (done) => { + user.post('/v1/pools') + .set('Authorization', dashboardAccessToken) + .send() + .expect((res: request.Response) => { + expect(isAddress(res.body.safeAddress)).toBe(true); + poolId = res.body._id; + }) + .expect(201, done); + }); + + it('POST /daily-rewards', (done) => { + user.post(`/v1/pools/${poolId}/quests/${QuestVariant.Daily}`) + .set({ 'X-PoolId': poolId, 'Authorization': dashboardAccessToken }) + .send({ + isPublished: true, + variant: QuestVariant.Daily, + title: 'Expiration date is next 30 min', + description: 'Lorem ipsum dolor sit amet', + amounts: JSON.stringify([100]), + eventName, + index: 0, + }) + .expect(async ({ body }: request.Response) => { + expect(body.uuid).toBeDefined(); + expect(body.eventName).toBeDefined(); + expect(body.amounts[0]).toBe(100); + dailyReward = body; + }) + .expect(201, done); + }); + + it('POST /webhook/daily/:uuid', async () => { + const { status } = await user.post(`/v1/webhook/daily/${eventName}`).send({ + address: account4.address, + }); + expect(status).toBe(201); + }); + + it('GET /participant to update identity', async () => { + const { status } = await user + .get(`/v1/participants`) + .query({ poolId }) + .set({ Authorization: widgetAccessToken4 }); + expect(status).toBe(200); + }); + + it('POST /quests/daily/:id/entries', async () => { + const { status, body } = await user + .post(`/v1/quests/daily/${dailyReward._id}/entries`) + .set({ 'X-PoolId': poolId, 'Authorization': widgetAccessToken4 }) + .send({ recaptcha: 'test' }); + expect(body.jobId).toBeDefined(); + expect(status).toBe(200); + + await poll( + () => Job.findById(body.jobId), + (job: any) => !job.lastRunAt, + 1000, + ); + + const job = await Job.findById(body.jobId); + expect(job.lastRunAt).toBeDefined(); + }); + + it('POST /quests/daily/:id/entries should throw an error', (done) => { + user.post(`/v1/quests/daily/${dailyReward._id}/entries`) + .set({ 'X-PoolId': poolId, 'Authorization': widgetAccessToken4 }) + .send({ recaptcha: 'test' }) + .expect(({ body }: request.Response) => { + expect(body.error).toBe('You have completed this quest within the last 24 hours.'); + }) + .expect(200, done); + }); +}); diff --git a/apps/api/src/app/controllers/webhook/daily/post.controller.ts b/apps/api/src/app/controllers/webhook/daily/post.controller.ts new file mode 100644 index 000000000..7813e179b --- /dev/null +++ b/apps/api/src/app/controllers/webhook/daily/post.controller.ts @@ -0,0 +1,34 @@ +import { BadRequestError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { Request, Response } from 'express'; +import { body, param } from 'express-validator'; +import { getIdentityForAddress, getIdentityForCode } from '../milestones/claim/post.controller'; +import { Event } from '@thxnetwork/api/models/Event'; +import { Pool, QuestDaily } from '@thxnetwork/api/models'; + +const validation = [ + param('uuid').isUUID('4'), + body('code').optional().isUUID(4), + body('address').optional().isEthereumAddress(), +]; + +const controller = async (req: Request, res: Response) => { + const quest = await QuestDaily.findOne({ eventName: req.params.uuid }); + if (!quest) throw new NotFoundError('Could not find a daily reward for this token'); + + const pool = await Pool.findById(quest.poolId); + if (!pool) throw new NotFoundError('Could not find a campaign pool for this reward.'); + + if (!req.body.code && !req.body.address) { + throw new BadRequestError('This request requires either a wallet code or address'); + } + + const identity = req.body.code + ? await getIdentityForCode(pool, req.body.code) + : await getIdentityForAddress(pool, req.body.address); + + await Event.create({ name: quest.eventName, identityId: identity._id, poolId: pool._id }); + + res.status(201).end(); +}; + +export { validation, controller }; diff --git a/apps/api/src/app/controllers/webhook/gateway/post.controller.ts b/apps/api/src/app/controllers/webhook/gateway/post.controller.ts new file mode 100644 index 000000000..008fd93bc --- /dev/null +++ b/apps/api/src/app/controllers/webhook/gateway/post.controller.ts @@ -0,0 +1,68 @@ +import { Request, Response } from 'express'; +import { body } from 'express-validator'; +import { WEBHOOK_SIGNING_SECRET } from '@thxnetwork/api/config/secrets'; +import { Wallet } from '@thxnetwork/api/models'; +import VoteEscrowService from '@thxnetwork/api/services/VoteEscrowService'; +import crypto from 'crypto'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import { logger } from '@thxnetwork/api/util/logger'; +import BalancerService from '@thxnetwork/api/services/BalancerService'; +import { formatUnits } from 'ethers/lib/utils'; + +const validation = [body('payload').isString(), body('signature').isString()]; + +// Helper method to verify payload signature +function constructEvent(payload, signature, secret) { + const hmac = crypto.createHmac('sha256', secret); + hmac.update(payload); + const calculatedSignature = hmac.digest('base64'); + if (signature !== calculatedSignature) throw new Error('Failed signature verification'); + return JSON.parse(payload); +} + +const controller = async (req: Request, res: Response) => { + let result = false; + try { + // Verifies and parses the payload using the WEBHOOK_SIGNING_SECRET which you can get in Developer -> Webhooks + const event = constructEvent(req.body.payload, req.body.signature, WEBHOOK_SIGNING_SECRET); + + switch (event.type) { + case 'quest_entry.create': { + const { identities, metadata } = event; + if (!identities.length) throw new Error('No identities found in the event'); + + const account = await AccountProxy.getByIdentity(identities[0]); + if (!account) throw new Error('No account found for the identity'); + + const wallets = await Wallet.find({ + sub: account.sub, + chainId: { $exists: true }, + address: { $exists: true }, + }); + if (!wallets.length) throw new Error('No wallets found for the account'); + + // Get largest lock and validate with provided metadata + const promises = wallets.map(async (wallet) => await VoteEscrowService.list(wallet)); + const locks = await Promise.all(promises); + const [largestLock] = locks.sort((a, b) => b.amount - a.amount); + const lockAmount = formatUnits(largestLock.amount, 18); + const bptPrice = BalancerService.pricing['20USDC-80THX']; + const largestAmountInUSD = Number(lockAmount) * bptPrice; + + result = largestAmountInUSD >= Number(metadata); + + break; + } + default: { + console.log('Unhandled event type ' + event.type); + } + } + + return res.json({ result }); + } catch (error) { + logger.error(error.message); + return res.status(400).send('Webhook Error: ' + error.message); + } +}; + +export { validation, controller }; diff --git a/apps/api/src/app/controllers/webhook/milestones/claim/post.controller.ts b/apps/api/src/app/controllers/webhook/milestones/claim/post.controller.ts new file mode 100644 index 000000000..2ca826363 --- /dev/null +++ b/apps/api/src/app/controllers/webhook/milestones/claim/post.controller.ts @@ -0,0 +1,49 @@ +import { Request, Response } from 'express'; +import { BadRequestError, NotFoundError } from '@thxnetwork/api/util/errors'; +import { body, param } from 'express-validator'; +import { toChecksumAddress } from 'web3-utils'; +import { Pool, PoolDocument, Identity, Event, QuestCustom } from '@thxnetwork/api/models'; +import IdentityService from '@thxnetwork/api/services/IdentityService'; + +const validation = [ + param('uuid').isUUID('4'), + body('code').optional().isUUID(4), + body('address') + .optional() + .isEthereumAddress() + .customSanitizer((address) => toChecksumAddress(address)), +]; + +const controller = async (req: Request, res: Response) => { + const customQuest = await QuestCustom.findOne({ uuid: req.params.uuid }); + if (!customQuest) throw new NotFoundError('Could not find a milestone reward for this token'); + + const pool = await Pool.findById(customQuest.poolId); + if (!pool) throw new NotFoundError('Could not find a campaign pool for this reward.'); + + if (!req.body.code && !req.body.address) { + throw new BadRequestError('This request requires either a wallet code or address'); + } + + const identity = req.body.code + ? await getIdentityForCode(pool, req.body.code) + : await getIdentityForAddress(pool, req.body.address); + + await Event.create({ name: customQuest.eventName, identityId: identity._id, poolId: pool._id }); + + res.status(201).end(); +}; + +export function getIdentityForCode(pool: PoolDocument, code: string) { + return Identity.findOne({ poolId: pool._id, uuid: code }); +} + +// @peterpolman (FK still depends on this) +// This function should deprecate as soon as clients implement the wallet onboarding webhook +// Defaulting into identity derivation for the provided address. This will require FK to present derived +// identity uuids in their client in order to connect the identity to their account. +export function getIdentityForAddress(pool: PoolDocument, address: string) { + return IdentityService.getIdentityForSalt(pool, address); +} + +export { validation, controller }; diff --git a/apps/api/src/app/controllers/webhook/webhook.router.ts b/apps/api/src/app/controllers/webhook/webhook.router.ts new file mode 100644 index 000000000..39fe2e0a8 --- /dev/null +++ b/apps/api/src/app/controllers/webhook/webhook.router.ts @@ -0,0 +1,16 @@ +import express from 'express'; +import { assertRequestInput } from '@thxnetwork/api/middlewares'; +import * as MilestoneReward from './milestones/claim/post.controller'; +import * as DailyReward from './daily/post.controller'; +import * as CreateController from './gateway/post.controller'; + +const router: express.Router = express.Router(); + +// Custom webhooks for Webhook Quest consumption +router.post('/gateway', assertRequestInput(CreateController.validation), CreateController.controller); + +// Deprecate soon +router.post('/milestone/:uuid/claim', assertRequestInput(MilestoneReward.validation), MilestoneReward.controller); +router.post('/daily/:uuid', assertRequestInput(DailyReward.validation), DailyReward.controller); + +export default router; diff --git a/apps/api/src/app/controllers/webhooks/delete.controller.ts b/apps/api/src/app/controllers/webhooks/delete.controller.ts new file mode 100644 index 000000000..5d0095c36 --- /dev/null +++ b/apps/api/src/app/controllers/webhooks/delete.controller.ts @@ -0,0 +1,12 @@ +import { param } from 'express-validator'; +import { Request, Response } from 'express'; +import { Webhook } from '@thxnetwork/api/models/Webhook'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + await Webhook.findByIdAndDelete(req.params.id); + res.status(204).end(); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/webhooks/list.controller.ts b/apps/api/src/app/controllers/webhooks/list.controller.ts new file mode 100644 index 000000000..5eaa09fab --- /dev/null +++ b/apps/api/src/app/controllers/webhooks/list.controller.ts @@ -0,0 +1,25 @@ +import { Request, Response } from 'express'; +import { Webhook, WebhookDocument } from '@thxnetwork/api/models/Webhook'; +import { WebhookRequest } from '@thxnetwork/api/models/WebhookRequest'; + +const validation = []; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['Webhooks'] + const poolId = req.header('x-poolid'); + const webhooks = await Webhook.find({ poolId }); + const response = await Promise.all( + webhooks.map(async (webhook: WebhookDocument) => { + const webhookRequests = await WebhookRequest.find({ webhookId: String(webhook._id) }) + .sort({ createdAt: -1 }) + .limit(50); + return { + ...webhook.toJSON(), + webhookRequests, + }; + }), + ); + res.json(response); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/webhooks/patch.controller.ts b/apps/api/src/app/controllers/webhooks/patch.controller.ts new file mode 100644 index 000000000..fc72b93ac --- /dev/null +++ b/apps/api/src/app/controllers/webhooks/patch.controller.ts @@ -0,0 +1,13 @@ +import { body, param } from 'express-validator'; +import { Request, Response } from 'express'; +import { Webhook } from '@thxnetwork/api/models/Webhook'; + +const validation = [param('id').isMongoId(), body('url').isURL({ require_tld: false })]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['Webhooks'] + const webhook = await Webhook.findByIdAndUpdate(req.params.id, { url: req.body.url }, { new: true }); + res.json(webhook); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/webhooks/post.controller.ts b/apps/api/src/app/controllers/webhooks/post.controller.ts new file mode 100644 index 000000000..7375b5210 --- /dev/null +++ b/apps/api/src/app/controllers/webhooks/post.controller.ts @@ -0,0 +1,17 @@ +import { body } from 'express-validator'; +import { Request, Response } from 'express'; +import { Webhook } from '@thxnetwork/api/models/Webhook'; + +const validation = [body('url').isURL({ require_tld: false })]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['Webhooks'] + const webhook = await Webhook.create({ + poolId: req.header('x-poolid'), + url: req.body.url, + }); + + res.status(201).json({ ...webhook.toJSON(), webhookRequests: [] }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/webhooks/webhooks.router.ts b/apps/api/src/app/controllers/webhooks/webhooks.router.ts new file mode 100644 index 000000000..2398c8be5 --- /dev/null +++ b/apps/api/src/app/controllers/webhooks/webhooks.router.ts @@ -0,0 +1,39 @@ +import express, { Router } from 'express'; +import { assertPoolAccess, assertRequestInput, guard } from '@thxnetwork/api/middlewares'; +import * as ListWebhook from './list.controller'; +import * as PatchWebhook from './patch.controller'; +import * as CreateWebhook from './post.controller'; +import * as DeleteWebhook from './delete.controller'; + +const router: express.Router = express.Router(); + +router.get( + '/', + guard.check(['webhooks:read']), + assertPoolAccess, + assertRequestInput(ListWebhook.validation), + ListWebhook.controller, +); +router.patch( + '/:id', + guard.check(['webhooks:read']), + assertPoolAccess, + assertRequestInput(PatchWebhook.validation), + PatchWebhook.controller, +); +router.post( + '/', + guard.check(['webhooks:write', 'webhooks:read']), + assertPoolAccess, + assertRequestInput(CreateWebhook.validation), + CreateWebhook.controller, +); +router.delete( + '/:id', + guard.check(['webhooks:write', 'webhooks:read']), + assertPoolAccess, + assertRequestInput(DeleteWebhook.validation), + DeleteWebhook.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/widget/get.controller.ts b/apps/api/src/app/controllers/widget/get.controller.ts new file mode 100644 index 000000000..0807ff26b --- /dev/null +++ b/apps/api/src/app/controllers/widget/get.controller.ts @@ -0,0 +1,31 @@ +import { AUTH_URL } from '@thxnetwork/api/config/secrets'; +import { Brand, Pool, Widget } from '@thxnetwork/api/models'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { Request, Response } from 'express'; +import { param } from 'express-validator'; + +const validation = [param('id').isMongoId()]; + +const controller = async (req: Request, res: Response) => { + const widget = await Widget.findOne({ poolId: req.params.id }); + if (!widget) throw new NotFoundError('Widget not found'); + + const pool = await Pool.findById(req.params.id); + if (!pool) throw new NotFoundError('Pool not found'); + + const brand = await Brand.findOne({ poolId: req.params.id }); + + res.json({ + title: pool.settings.title, + description: pool.settings.description, + logoUrl: brand ? brand.logoImgUrl : AUTH_URL + '/img/logo-padding.png', + backgroundUrl: brand ? brand.backgroundImgUrl : '', + theme: widget.theme, + domain: widget.domain, + chainId: pool.chainId, + poolId: pool._id, + slug: pool.settings.slug || pool._id, + }); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/widget/js/get.controller.ts b/apps/api/src/app/controllers/widget/js/get.controller.ts new file mode 100644 index 000000000..8d932ae4b --- /dev/null +++ b/apps/api/src/app/controllers/widget/js/get.controller.ts @@ -0,0 +1,566 @@ +import { API_URL, AUTH_URL, DASHBOARD_URL, NODE_ENV, WIDGET_URL } from '@thxnetwork/api/config/secrets'; +import BrandService from '@thxnetwork/api/services/BrandService'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import { QuestInvite } from '@thxnetwork/api/models/QuestInvite'; +import { Widget } from '@thxnetwork/api/models/Widget'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; +import { Request, Response } from 'express'; +import { query, param } from 'express-validator'; +import { minify } from 'terser'; + +const validation = [ + param('id').isMongoId(), + query('identity').optional().isUUID(), + query('containerSelector').optional().isString(), +]; + +const controller = async (req: Request, res: Response) => { + // #swagger.tags = ['Widget'] + const referralRewards = await QuestInvite.find({ + poolId: req.params.id, + }); + const refs = JSON.stringify( + referralRewards + .filter((r) => r.successUrl) + .map((r) => { + return { + uuid: r.uuid, + successUrl: r.successUrl, + }; + }), + ); + + const pool = await PoolService.getById(req.params.id); + if (!pool) throw new NotFoundError('Pool not found.'); + + const expired = pool.settings.endDate ? pool.settings.endDate.getTime() <= Date.now() : false; + const brand = await BrandService.get(pool._id); + const widget = await Widget.findOne({ poolId: req.params.id }); + const referrerHeader = req.header('Referrer'); + const widgetOrigin = widget.domain ? new URL(widget.domain).origin : ''; + const origin = referrerHeader ? new URL(referrerHeader).origin : ''; + + // Set active to true if there is a request made from the configured domain + if (widgetOrigin === origin && !widget.active) { + await widget.updateOne({ active: true }); + } + + const data = ` +if (typeof window.THXWidget !== 'undefined') { + window.THXWidget.onLoad(); +} else { + class THXWidget { + MD_BREAKPOINT = 990; + public isAuthenticated = false; + + constructor(settings) { + this.settings = settings; + this.theme = JSON.parse(settings.theme); + this.init(); + } + + get defaultStyles() { + const isCustomContainer = !!this.settings.containerSelector; + return { + sm: { + width: '100%', + height: '100%', + maxHeight: 'none', + top: 0, + left: 0, + right: 0, + bottom: 0, + border: 0, + borderRadius: 0, + }, + md: { + top: 'auto', + bottom: isCustomContainer ? 'auto' : '100px', + maxHeight: isCustomContainer ? 'none' : '703px', + width: isCustomContainer ? '100%' : '400px', + border: 0, + borderRadius: isCustomContainer ? '0px' : '10px', + height: isCustomContainer ? '100%' : 'calc(100% - 115px)', + }, + } + } + + init() { + const waitForBody = () => new Promise((resolve) => { + const tick = () => { + if (document.getElementsByTagName('body').length) { + clearInterval(timer) + resolve() + } + } + const timer = setInterval(tick, 1000); + }); + waitForBody().then(this.onLoad.bind(this)); + } + + public setIdentity(identity) { + this.iframe.contentWindow.postMessage({ message: 'thx.auth.identity', identity }, this.settings.widgetUrl); + } + + public signin() { + this.iframe.contentWindow.postMessage({ message: 'thx.auth.signin' }, this.settings.widgetUrl); + } + + public signout() { + this.iframe.contentWindow.postMessage({ message: 'thx.auth.signout' }, this.settings.widgetUrl); + } + + public open(widgetPath) { + if (!widgetPath) return; + + const { widgetUrl, poolId, chainId, theme } = this.settings; + const path = '/c/' + poolId + widgetPath; + const isMobile = window.matchMedia('(pointer:coarse)').matches; + + if (isMobile) { + // Window _blank will be blocked by mobile OS so we redirect the current window + window.location.href = this.settings.widgetUrl + path + } else { + this.iframe.contentWindow.postMessage({ message: 'thx.iframe.navigate', path }, widgetUrl); + this.show(true); + } + } + + public connect(uuid) { + this.open('/w/' + uuid); + } + + public quests = { + list: () => { + this.iframe.contentWindow.postMessage({ message: 'thx.quests.list' }, this.settings.widgetUrl); + } + } + + onLoad() { + this.referrals = JSON.parse(this.settings.refs).filter((r) => r.successUrl); + this.iframe = this.createIframe(); + + if (this.settings.isPublished) { + this.notifications = this.createNotifications(0); + this.message = this.createMessage(); + this.launcher = this.settings.cssSelector ? this.selectLauncher() : this.createLauncher(); + this.container = this.createContainer(this.iframe, this.launcher, this.message); + } + + this.parseURL(); + + window.matchMedia('(max-width: 990px)').addListener(this.onMatchMedia.bind(this)); + window.onmessage = this.onMessage.bind(this); + } + + parseURL() { + const url = new URL(window.location.href) + this.ref = url.searchParams.get('ref'); + if (!this.ref) return; + + this.successUrls = this.referrals.map((r) => r.successUrl); + if (!this.successUrls.length) return; + } + + get isSmallMedia() { + const getWidth = () => window.innerWidth; + return getWidth() < this.MD_BREAKPOINT; + } + + createURL() { + const parentUrl = new URL(window.location.href) + const path = parentUrl.searchParams.get('thx_widget_path'); + const { widgetUrl, poolId, chainId, theme, expired, logoUrl, backgroundUrl, title } = this.settings; + const url = new URL(widgetUrl); + + url.pathname = this.widgetPath = '/c/' + poolId + (path || '/quests'); + url.searchParams.append('origin', window.location.origin); + + return url; + } + + createIframe() { + const { widgetUrl, poolId, chainId, theme, align, expired, containerSelector } = this.settings; + const iframe = document.createElement('iframe'); + const styles = this.isSmallMedia ? this.defaultStyles['sm'] : this.defaultStyles['md']; + const url = this.createURL(); + + iframe.id = 'thx-iframe'; + iframe.src = url; + iframe.setAttribute('data-hj-allow-iframe', true); + + if (containerSelector) { + Object.assign(iframe.style, this.defaultStyles[this.isSmallMedia ? 'sm' : 'md']); + return iframe; + } + + let top, bottom, left, right, marginLeft, marginTop, transformOrigin; + + if (!this.isSmallMedia) { + switch(align) { + case 'left' : + top = 'auto'; + bottom = !this.settings.cssSelector ? '100px' : '15px'; + left = '15px'; + right = 'auto'; + transformOrigin = 'bottom left'; + break; + case 'right' : + top = 'auto'; + bottom = !this.settings.cssSelector ? '100px' : '15px'; + left = 'auto'; + right = '15px'; + transformOrigin = 'bottom right'; + break; + case 'center' : + top = '50%'; + left = '50%'; + right = 'auto'; + bottom = 'auto'; + marginLeft = '-200px'; + marginTop = '-340px'; + transformOrigin = 'center center'; + break; + } + } else { + top = '0'; + bottom = '0'; + left = '0'; + right = '0'; + } + + Object.assign(iframe.style, { + ...styles, + zIndex: 99999999, + display: 'flex', + top, + bottom, + left, + right, + marginLeft, + marginTop, + position: 'fixed', + border: '0', + opacity: '0', + boxShadow: 'rgba(50, 50, 93, 0.25) 0px 50px 100px -20px, rgba(0, 0, 0, 0.3) 0px 30px 60px -30px', + transform: 'scale(0)', + transformOrigin, + transition: '.2s opacity ease, .1s transform ease', + }); + + return iframe; + } + + createNotifications(counter) { + const notifications = document.createElement('div'); + notifications.id = 'thx-notifications'; + Object.assign(notifications.style, { + display: 'none', + fontFamily: 'Arial', + fontSize: '13px', + justifyContent: 'center', + alignItems: 'center', + width: '20px', + height: '20px', + color: '#FFFFFF', + position: 'absolute', + backgroundColor: '#CA0000', + borderRadius: '50%', + userSelect: 'none', + }); + notifications.innerHTML = counter; + return notifications; + } + + createMessage() { + const { message, logoUrl, align } = this.settings; + const messageBox = document.createElement('div'); + const closeBox = document.createElement('button'); + + messageBox.id = 'thx-message'; + + closeBox.innerHTML = '×'; + + Object.assign(closeBox.style, { + display: 'flex', + fontFamily: 'Arial', + fontSize: '16px', + justifyContent: 'center', + alignItems: 'center', + width: '20px', + height: '20px', + border: '0', + color: '#000000', + position: 'absolute', + backgroundColor: 'transparent', + top: '0', + right: '0', + opacity: '0.5', + transform: 'scale(.9)', + transition: '.2s opacity ease, .1s transform ease', + }); + closeBox.addEventListener('mouseenter', () => { + closeBox.style.opacity = '1'; + closeBox.style.transform = 'scale(1)'; + }); + closeBox.addEventListener('mouseleave', () => { + closeBox.style.opacity = '.5'; + closeBox.style.transform = 'scale(.9)'; + }); + closeBox.addEventListener('click', () => { + this.message.remove(); + }); + + Object.assign(messageBox.style, { + zIndex: 9999999, + display: message ? 'flex' : 'none', + lineHeight: 1.5, + fontFamily: 'inherit, sans-serif', + fontSize: '12px', + fontWeight: 'normal', + justifyContent: 'center', + alignItems: 'center', + width: '200px', + color: '#000000', + position: 'fixed', + backgroundColor: '#FFFFFF', + borderRadius: '5px', + userSelect: 'none', + padding: '10px 10px 10px', + bottom: '90px', + right: align === 'right' ? '15px' : 'auto', + left: align === 'left' ? '15px' : 'auto', + boxShadow: 'rgb(50 50 93 / 25%) 0px 50px 100px -20px, rgb(0 0 0 / 30%) 0px 30px 60px -30px', + opacity: 0, + transform: 'scale(0)', + transition: '.2s opacity ease, .1s transform ease', + }); + + const wrapper = document.createElement('span'); + wrapper.style.zIndex = 0; + wrapper.innerHTML = message; + messageBox.appendChild(wrapper); + messageBox.appendChild(closeBox); + + return messageBox; + } + + selectLauncher() { + const launcher = document.querySelector(this.settings.cssSelector); + if (!launcher) { + console.error("THX widget can't find the launcher for selector: " + this.settings.cssSelector); + return; + } + + launcher.addEventListener('click', this.onClickLauncher.bind(this)); + + setTimeout(() => { + const url = new URL(window.location.href) + const widgetPath = url.searchParams.get('thx_widget_path'); + + this.show(!!widgetPath); + }, 350); + + return launcher; + } + + createLauncher() { + const svgGift = this.settings.iconImg + ? 'Widget launcher icon' + : ''; + const launcher = document.createElement('div'); + launcher.id = 'thx-launcher'; + + Object.assign(launcher.style, { + zIndex: 9999999, + display: 'flex', + width: '60px', + height: '60px', + backgroundColor: this.theme.elements.launcherBg.color, + borderRadius: '50%', + cursor: 'pointer', + position: 'fixed', + bottom: '15px', + right: !this.settings.cssSelector ? this.settings.align === 'right' ? '15px' : 'auto' : 'auto', + left: !this.settings.cssSelector ? this.settings.align === 'left' ? '15px' : 'auto' : 'auto', + opacity: 0, + transition: '.2s opacity ease, .1s transform ease', + }); + + launcher.innerHTML = svgGift; + launcher.addEventListener('click', this.onClickLauncher.bind(this)); + launcher.appendChild(this.notifications); + + setTimeout(() => { + launcher.style.opacity = 1; + launcher.style.transform = 'scale(1)'; + + this.message.style.opacity = 1; + this.message.style.transform = 'scale(1)'; + + const url = new URL(window.location.href) + const widgetPath = url.searchParams.get('thx_widget_path'); + this.show(!!widgetPath) + }, 350); + + return launcher; + } + + createContainer(iframe, launcher, message) { + const { containerSelector, cssSelector } = this.settings; + let container; + if (containerSelector) { + container = document.querySelector(containerSelector) + if (!container) throw new Error("Could not find an HTML element for selector: '" + containerSelector + "'.") + container.appendChild(iframe); + } else { + container = document.createElement('div'); + container.id = 'thx-container'; + container.appendChild(iframe); + + if (!cssSelector) { + container.appendChild(launcher); + container.appendChild(message); + } + + document.body.appendChild(container); + } + return container; + } + + storeRef(ref) { + if (!ref) return; + + window.localStorage.setItem('thx:widget:' + this.settings.poolId + ':ref', ref); + this.iframe.contentWindow.postMessage({ message: 'thx.config.ref', ref }, this.settings.widgetUrl); + this.timer = window.setInterval(this.onURLDetectionCallback.bind(this), 500); + } + + onMessage(event) { + if (event.origin !== this.settings.widgetUrl) return; + const { message, amount, isAuthenticated, url } = event.data; + switch (message) { + case 'thx.auth.signin': { + this.onSignin(url); + break + } + case 'thx.widget.ready': { + this.onWidgetReady(); + break + } + case 'thx.reward.amount': { + this.notifications.innerText = amount; + this.notifications.style.display = amount ? 'flex' : 'none'; + break; + } + case 'thx.widget.toggle': { + this.show(!Number(this.iframe.style.opacity)); + break; + } + case 'thx.auth.status': { + this.isAuthenticated = isAuthenticated; + break; + } + } + } + + onSignin(url) { + window.open(url, '_blank'); + } + + onClickLauncher() { + const isMobile = window.matchMedia('(pointer:coarse)').matches; + if (window.ethereum && isMobile) { + const deeplink = 'https://metamask.app.link/dapp/'; + const ua = navigator.userAgent.toLowerCase(); + const isAndroid = ua.indexOf("android") > -1; + const url = isAndroid ? deeplink + this.createURL() : this.createURL(); + window.open(url, '_blank'); + } else if (!window.ethereum && isMobile) { + window.open(this.createURL(), '_blank'); + } else { + this.show(!Number(this.iframe.style.opacity)); + } + this.message.remove(); + } + + onWidgetReady() { + const parentUrl = new URL(window.location.href) + const widgetPath = parentUrl.searchParams.get('thx_widget_path'); + + this.open(widgetPath); + this.storeRef(this.ref); + + if (this.settings.identity) { + this.setIdentity(this.settings.identity) + } + } + + show(isShown) { + const { containerSelector } = this.settings; + const shouldShow = isShown || !!containerSelector; + + this.iframe.style.opacity = shouldShow ? '1' : '0'; + this.iframe.style.transform = shouldShow ? 'scale(1)' : 'scale(0)'; + + if (this.iframe.contentWindow) { + this.iframe.contentWindow.postMessage({ message: 'thx.iframe.show', shouldShow }, this.settings.widgetUrl); + } + + if (shouldShow) this.message.remove(); + } + + onURLDetectionCallback() { + for (const ref of this.referrals) { + if (!(this.successUrls.filter((url) => url.includes(window.location.origin + window.location.pathname))).length) continue; + this.iframe.contentWindow.postMessage({ message: 'thx.referral.claim.create', uuid: ref.uuid, }, this.settings.widgetUrl); + + const index = this.referrals.findIndex((r) => ref.uuid); + this.referrals.splice(index, 1); + } + + if (!this.referrals.length) { + window.clearInterval(this.timer); + } + } + + onMatchMedia(x) { + if (x.matches) { + const iframe = document.getElementById('thx-iframe'); + Object.assign(iframe.style, this.defaultStyles['sm']); + } else { + const iframe = document.getElementById('thx-iframe'); + Object.assign(iframe.style, this.defaultStyles['md']); + } + } + } + window.THXWidget = new THXWidget({ + apiUrl: '${API_URL}', + isPublished: window.location.origin.includes("${DASHBOARD_URL}") || ${widget.isPublished}, + widgetUrl: '${WIDGET_URL}', + poolId: '${req.params.id}', + chainId: '${pool.chainId}', + title: '${pool.settings.title}', + cssSelector: '${widget.cssSelector || ''}', + logoUrl: '${brand && brand.logoImgUrl ? brand.logoImgUrl : AUTH_URL + '/img/logo-padding.png'}', + backgroundUrl: '${brand && brand.backgroundImgUrl ? brand.backgroundImgUrl : ''}', + iconImg: '${widget.iconImg || ''}', + message: '${widget.message || ''}', + align: '${widget.align || 'right'}', + theme: '${widget.theme}', + refs: ${JSON.stringify(refs)}, + expired: '${expired}', + identity: '${req.query.identity || ''}', + containerSelector: '${req.query.containerSelector || ''}' + }); +} +`; + const result = await minify(data, { + mangle: { toplevel: false }, + sourceMap: NODE_ENV !== 'production', + }); + + res.set({ 'Content-Type': 'application/javascript' }).send(result.code); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/widget/widget.router.ts b/apps/api/src/app/controllers/widget/widget.router.ts new file mode 100644 index 000000000..bfaf56d29 --- /dev/null +++ b/apps/api/src/app/controllers/widget/widget.router.ts @@ -0,0 +1,29 @@ +import express, { Request, Response, NextFunction } from 'express'; +import { assertRequestInput } from '@thxnetwork/api/middlewares'; +import * as ReadWidget from './get.controller'; +import * as ReadWidgetScript from './js/get.controller'; +import { Pool } from '@thxnetwork/api/models'; + +const router: express.Router = express.Router(); + +router.get('/:id.:ext', assertRequestInput(ReadWidgetScript.validation), ReadWidgetScript.controller); +router.get( + '/:id', + async (req: Request, res: Response, next: NextFunction) => { + const isMongoId = (str: string) => { + const objectIdPattern = /^[0-9a-fA-F]{24}$/; + return objectIdPattern.test(str); + }; + + if (!isMongoId(req.params.id)) { + const pool = await Pool.findOne({ 'settings.slug': req.params.id }); + req.params.id = String(pool._id); + } + + next(); + }, + assertRequestInput(ReadWidget.validation), + ReadWidget.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/widgets/get.controller.ts b/apps/api/src/app/controllers/widgets/get.controller.ts new file mode 100644 index 000000000..4526c8f58 --- /dev/null +++ b/apps/api/src/app/controllers/widgets/get.controller.ts @@ -0,0 +1,12 @@ +import { Request, Response } from 'express'; +import { param } from 'express-validator'; +import { Widget } from '@thxnetwork/api/models'; + +const validation = [param('uuid').exists()]; + +const controller = async (req: Request, res: Response) => { + const widget = await Widget.findOne({ uuid: req.params.uuid }); + res.json(widget); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/widgets/list.controller.ts b/apps/api/src/app/controllers/widgets/list.controller.ts new file mode 100644 index 000000000..0ff3a66b7 --- /dev/null +++ b/apps/api/src/app/controllers/widgets/list.controller.ts @@ -0,0 +1,9 @@ +import { Request, Response } from 'express'; +import { Widget } from '@thxnetwork/api/models'; + +const controller = async (req: Request, res: Response) => { + const widgets = await Widget.find({ poolId: req.header('X-PoolId') }); + res.json(widgets); +}; + +export { controller }; diff --git a/apps/api/src/app/controllers/widgets/patch.controller.ts b/apps/api/src/app/controllers/widgets/patch.controller.ts new file mode 100644 index 000000000..b994d386d --- /dev/null +++ b/apps/api/src/app/controllers/widgets/patch.controller.ts @@ -0,0 +1,33 @@ +import { Request, Response } from 'express'; +import { body } from 'express-validator'; +import { Widget } from '@thxnetwork/api/models'; + +const validation = [ + body('isPublished').optional().isBoolean(), + body('iconImg').optional().isString(), + body('align').optional().isString(), + body('theme').optional().isString(), + body('cssSelector').optional().isString(), + body('domain').optional().isURL({ require_tld: false }), + body('message').optional().isString().isLength({ max: 280 }).trim().escape(), +]; + +const controller = async (req: Request, res: Response) => { + const widget = await Widget.findOneAndUpdate( + { uuid: req.params.uuid }, + { + isPublished: req.body.isPublished, + iconImg: req.body.iconImg, + color: req.body.color, + align: req.body.align, + domain: req.body.domain, + message: req.body.message, + theme: req.body.theme, + cssSelector: req.body.cssSelector, + }, + { new: true }, + ); + return res.json(widget); +}; + +export { controller, validation }; diff --git a/apps/api/src/app/controllers/widgets/widgets.router.ts b/apps/api/src/app/controllers/widgets/widgets.router.ts new file mode 100644 index 000000000..be680b0b7 --- /dev/null +++ b/apps/api/src/app/controllers/widgets/widgets.router.ts @@ -0,0 +1,25 @@ +import express, { Router } from 'express'; +import { assertPoolAccess, assertRequestInput, guard } from '@thxnetwork/api/middlewares'; +import * as ReadWidget from './get.controller'; +import * as UpdateWidget from './patch.controller'; +import * as ListWidgets from './list.controller'; + +const router: express.Router = express.Router(); + +router.get('/', guard.check(['widgets:read']), assertPoolAccess, ListWidgets.controller); +router.get( + '/:uuid', + guard.check(['widgets:read']), + assertRequestInput(ReadWidget.validation), + assertPoolAccess, + ReadWidget.controller, +); +router.patch( + '/:uuid', + guard.check(['widgets:write', 'widgets:read']), + assertRequestInput(UpdateWidget.validation), + assertPoolAccess, + UpdateWidget.controller, +); + +export default router; diff --git a/apps/api/src/app/controllers/widgets/widgets.test.ts b/apps/api/src/app/controllers/widgets/widgets.test.ts new file mode 100644 index 000000000..7f86ace8b --- /dev/null +++ b/apps/api/src/app/controllers/widgets/widgets.test.ts @@ -0,0 +1,73 @@ +import request, { Response } from 'supertest'; +import app from '@thxnetwork/api/'; +import { ChainId } from '@thxnetwork/common/enums'; +import { dashboardAccessToken } from '@thxnetwork/api/util/jest/constants'; +import { afterAllCallback, beforeAllCallback } from '@thxnetwork/api/util/jest/config'; +import { WidgetDocument } from '@thxnetwork/api/models/Widget'; + +const user = request.agent(app); + +describe('Widgets', () => { + let poolId: string, widget: WidgetDocument; + const newTheme = + '{"elements":{"btnBg":{"label":"Button","color":"#FF0000"},"btnText":{"label":"Button Text","color":"#000000"},"text":{"label":"Text","color":"#ffffff"},"bodyBg":{"label":"Background","color":"#000000"},"cardBg":{"label":"Card","color":"#3b3b3b"},"navbarBg":{"label":"Navigation","color":"#3b3b3b"},"launcherBg":{"label":"Launcher","color":"#ffffff"},"launcherIcon":{"label":"Launcher Icon","color":"#000000"}},"colors":{"success":{"label":"Success","color":"#28a745"},"warning":{"label":"Warning","color":"#ffe500"},"danger":{"label":"Danger","color":"#dc3545"},"info":{"label":"Info","color":"#17a2b8"}}}', + align = 'left', + message = 'New message', + iconImg = 'https://image.icon'; + + beforeAll(beforeAllCallback); + afterAll(afterAllCallback); + + it('POST /pools', (done) => { + user.post('/v1/pools') + .set({ Authorization: dashboardAccessToken }) + .send({ chainId: ChainId.Hardhat }) + .expect(({ body }: Response) => { + poolId = body._id; + }) + .expect(201, done); + }); + + it('GET /widgets', (done) => { + user.get('/v1/widgets') + .set({ 'X-PoolId': poolId, 'Authorization': dashboardAccessToken }) + .expect(({ body }: Response) => { + expect(body[0].uuid).toBeDefined(); + expect(body[0].theme).toBeDefined(); + expect(body[0].message).toEqual('Hi there!👋 Click me to complete quests and earn rewards...'); + widget = body[0]; + }) + .expect(200, done); + }); + + it('PATCH /widgets/:uuid', (done) => { + user.patch('/v1/widgets/' + widget.uuid) + .set({ 'X-PoolId': poolId, 'Authorization': dashboardAccessToken }) + .send({ + iconImg, + align, + message, + theme: newTheme, + }) + .expect(({ body }: Response) => { + expect(body.iconImg).toBe(iconImg); + expect(body.uuid).toBeDefined(); + expect(body.theme).toEqual(newTheme); + expect(body.message).toEqual(message); + expect(body.align).toEqual(align); + }) + .expect(200, done); + }); + + it('GET /widgets/:uuid', (done) => { + user.get('/v1/widgets/' + widget.uuid) + .set({ 'X-PoolId': poolId, 'Authorization': dashboardAccessToken }) + .expect(({ body }: Response) => { + expect(body.iconImg).toBe(iconImg); + expect(body.uuid).toBeDefined(); + expect(body.theme).toEqual(newTheme); + expect(body.message).toEqual(message); + }) + .expect(200, done); + }); +}); diff --git a/apps/api/src/app/events/ClientReady.ts b/apps/api/src/app/events/ClientReady.ts new file mode 100644 index 000000000..64dc61a40 --- /dev/null +++ b/apps/api/src/app/events/ClientReady.ts @@ -0,0 +1,11 @@ +import { commands } from './commands/thx'; +import { Client } from 'discord.js'; +import { commandRegister } from '@thxnetwork/api/util/discord'; +import { logger } from '@thxnetwork/api/util/logger'; + +const onClientReady = async (client: Client) => { + logger.info(`Ready! Logged in as ${client.user.tag}`); + await commandRegister(commands); +}; + +export default onClientReady; diff --git a/apps/api/src/app/events/GuildCreate.ts b/apps/api/src/app/events/GuildCreate.ts new file mode 100644 index 000000000..ea8a558c1 --- /dev/null +++ b/apps/api/src/app/events/GuildCreate.ts @@ -0,0 +1,17 @@ +import { Guild } from 'discord.js'; +import { logger } from '@thxnetwork/api/util/logger'; +import { handleError } from './commands/error'; + +const onGuildCreate = async (guild: Guild) => { + logger.info(`Added to guild: ${guild.name}`); + try { + const member = await guild.members.fetch(guild.ownerId); + await member.send({ + content: 'THX for the invite!🙏 Make sure to connect your campaign in THX Dashboard.', + }); + } catch (error) { + handleError(error); + } +}; + +export default onGuildCreate; diff --git a/apps/api/src/app/events/GuildDelete.ts b/apps/api/src/app/events/GuildDelete.ts new file mode 100644 index 000000000..b69ea94b8 --- /dev/null +++ b/apps/api/src/app/events/GuildDelete.ts @@ -0,0 +1,15 @@ +import { Guild } from 'discord.js'; +import { logger } from '@thxnetwork/api/util/logger'; +import { handleError } from './commands/error'; +import { DiscordGuild } from '@thxnetwork/api/models'; + +const onGuildDelete = async (guild: Guild) => { + try { + logger.info(`Removed campaign references for guild: ${guild.name}`); + await DiscordGuild.deleteMany({ guildId: guild.id }); + } catch (error) { + handleError(error); + } +}; + +export default onGuildDelete; diff --git a/apps/api/src/app/events/InteractionCreated.ts b/apps/api/src/app/events/InteractionCreated.ts new file mode 100644 index 000000000..3985d902b --- /dev/null +++ b/apps/api/src/app/events/InteractionCreated.ts @@ -0,0 +1,75 @@ +import { + ChatInputCommandInteraction, + StringSelectMenuInteraction, + ButtonInteraction, + AutocompleteInteraction, +} from 'discord.js'; +import { handleError } from './commands/error'; +import { onSelectQuestComplete, onClickQuestComplete, onClickRewardList, onClickQuestList } from './handlers/index'; +import { logger } from '../util/logger'; +import { DiscordGuild } from '@thxnetwork/api/models'; +import { Pool, PoolDocument } from '@thxnetwork/api/models'; +import router from './commands/thx'; + +export enum DiscordStringSelectMenuVariant { + QuestComplete = 'thx.campaign.quest.entry.create', + RewardBuy = 'thx.campaign.reward.payment.create', +} + +export enum DiscordButtonVariant { + RewardBuy = 'thx.campaign.reward.payment.create', + QuestComplete = 'thx.campaign.quest.entry.create', + QuestList = 'thx.campaign.quest.list', + RewardList = 'thx.campaign.reward.list', +} + +const stringSelectMenuMap = { + [DiscordStringSelectMenuVariant.QuestComplete]: onSelectQuestComplete, +}; + +export const onAutoComplete = async (interaction: AutocompleteInteraction) => { + if (!interaction.isAutocomplete()) return; + + const discordGuilds = await DiscordGuild.find({ guildId: interaction.guildId }); + const focusedValue = interaction.options.getFocused(); + const campaigns = await Promise.all(discordGuilds.map(({ poolId }) => Pool.findById(poolId))); + const choices = campaigns.filter((c) => !!c).map((c: PoolDocument) => `${c.settings.title}`); + const filtered = choices.filter((choice) => choice.startsWith(focusedValue)); + + await interaction.respond(filtered.map((choice) => ({ name: choice, value: choice }))); +}; + +const onInteractionCreated = async ( + interaction: ButtonInteraction | ChatInputCommandInteraction | StringSelectMenuInteraction, +) => { + try { + if (interaction.isButton()) { + logger.info(`#${interaction.user.id} clicked button #${interaction.customId}`); + if (interaction.customId.startsWith(DiscordButtonVariant.QuestComplete)) { + await onClickQuestComplete(interaction); + } + if (interaction.customId.startsWith(DiscordButtonVariant.QuestList)) { + await onClickQuestList(interaction); + } + if (interaction.customId.startsWith(DiscordButtonVariant.RewardList)) { + await onClickRewardList(interaction); + } + } + + if (interaction.isStringSelectMenu()) { + logger.info(`#${interaction.user.id} picked ${interaction.values[0]} for ${interaction.customId}`); + if (!stringSelectMenuMap[interaction.customId]) + throw new Error('Support for this action is not yet implemented!'); + await stringSelectMenuMap[interaction.customId](interaction); + } + + if (interaction.isCommand()) { + logger.info(`#${interaction.user.id} ran /${interaction.commandName}`); + router.executor(interaction); + } + } catch (error) { + handleError(error, interaction); + } +}; + +export default onInteractionCreated; diff --git a/apps/api/src/app/events/MessageCreate.ts b/apps/api/src/app/events/MessageCreate.ts new file mode 100644 index 000000000..ae2a63fb3 --- /dev/null +++ b/apps/api/src/app/events/MessageCreate.ts @@ -0,0 +1,59 @@ +import { Message } from 'discord.js'; +import { logger } from '../util/logger'; +import { DiscordMessage, DiscordGuild, QuestSocial, QuestSocialDocument } from '@thxnetwork/api/models'; +import { QuestSocialRequirement } from '@thxnetwork/common/enums'; +import AccountProxy from '../proxies/AccountProxy'; + +const onMessageCreate = async (message: Message) => { + try { + // Only record messages for connected accounts + const connectedAccount = await AccountProxy.getByDiscordId(message.author.id); + if (!connectedAccount) return; + + logger.info(`#${message.author.id} created message ${message.id} in guild ${message.guild.id}`); + + const start = new Date(); + start.setUTCHours(0, 0, 0, 0); + + const end = new Date(start); + end.setUTCHours(23, 59, 59, 999); + + const guild = await DiscordGuild.findOne({ guildId: message.guild.id }); + const quests = await QuestSocial.find({ + poolId: guild.poolId, + interaction: QuestSocialRequirement.DiscordMessage, + }); + if (!quests.length) return; + + // Return early if channel is not eligble for message tracking + const allowedChannels = quests.reduce((list: string[], q: QuestSocialDocument) => { + const { channels } = JSON.parse(q.contentMetadata); + return [...list, ...channels]; + }, []); + if (!allowedChannels.includes(message.channelId)) return; + + // Count the total amount of messages for today + const dailyMessageCount = await DiscordMessage.countDocuments({ + guildId: message.guild.id, + memberId: message.author.id, + createdAt: { $gte: start, $lt: end }, + }); + + // Get the highest limit for all available discord message quests in this campaign + const dailyMessageLimit = quests.reduce((highestLimit: number, quest: QuestSocialDocument) => { + const { limit } = JSON.parse(quest.contentMetadata); + return limit > highestLimit ? limit : highestLimit; + }, 0); + + // Only track messages if daily limit has not been surpassed + if (dailyMessageCount > dailyMessageLimit) return; + + // Store the message + const payload = { messageId: message.id, guildId: message.guild.id, memberId: message.author.id }; + await DiscordMessage.findOneAndUpdate(payload, payload, { upsert: true }); + } catch (error) { + logger.error(error); + } +}; + +export default onMessageCreate; diff --git a/apps/api/src/app/events/MessageReactionAdd.ts b/apps/api/src/app/events/MessageReactionAdd.ts new file mode 100644 index 000000000..3fc680414 --- /dev/null +++ b/apps/api/src/app/events/MessageReactionAdd.ts @@ -0,0 +1,33 @@ +import { MessageReaction } from 'discord.js'; +import { logger } from '../util/logger'; +import { DiscordReaction } from '@thxnetwork/api/models'; + +const onMessageReactionAdd = async (reaction: MessageReaction) => { + try { + const users = await reaction.users.fetch(); + const promises = users.map((user) => { + try { + // logger.info( + // `#${user.id} created a reaction on message ${reaction.message.id} in guild ${reaction.message.guild.id}`, + // ); + const filter = { + guildId: reaction.message.guild.id, + messageId: reaction.message.id, + memberId: user.id, + }; + return DiscordReaction.findOneAndUpdate( + filter, + { ...filter, content: reaction['_emoji'].name }, + { upsert: true }, + ); + } catch (error) { + logger.error(error); + } + }); + await Promise.all(promises); + } catch (error) { + logger.error(error); + } +}; + +export default onMessageReactionAdd; diff --git a/apps/api/src/app/events/commands/error.ts b/apps/api/src/app/events/commands/error.ts new file mode 100644 index 000000000..4f96d24a7 --- /dev/null +++ b/apps/api/src/app/events/commands/error.ts @@ -0,0 +1,20 @@ +import { ButtonInteraction, CommandInteraction, StringSelectMenuInteraction } from 'discord.js'; +import { logger } from '@thxnetwork/api/util/logger'; + +export const handleError = async ( + error: Error, + interaction?: ButtonInteraction | CommandInteraction | StringSelectMenuInteraction, +) => { + logger.info(error); + try { + if (interaction && interaction.isRepliable() && error.message) { + await interaction.reply({ + content: error.message, + ephemeral: true, + }); + } + } catch (error) { + logger.info(error); + // If the error reply fails we exit silently but log the cause + } +}; diff --git a/apps/api/src/app/events/commands/index.ts b/apps/api/src/app/events/commands/index.ts new file mode 100644 index 000000000..84139a19b --- /dev/null +++ b/apps/api/src/app/events/commands/index.ts @@ -0,0 +1,11 @@ +import quest from './thx/quest'; +import buy from './thx/buy'; +import points from './thx/points'; +import info from './thx/info'; + +export default { + quest, + buy, + points, + info, +}; diff --git a/apps/api/src/app/events/commands/thx.ts b/apps/api/src/app/events/commands/thx.ts new file mode 100644 index 000000000..78491ae1e --- /dev/null +++ b/apps/api/src/app/events/commands/thx.ts @@ -0,0 +1,64 @@ +import { CommandInteraction, SlashCommandBuilder } from 'discord.js'; +import { + DiscordCommandVariant, + onSubcommandBuy, + onSubcommandComplete, + onSubcommandInfo, + onSubcommandPoints, +} from './thx/index'; + +export const commands: any[] = [ + new SlashCommandBuilder() + .setName('info') + .setDescription('View your rank, quests and rewards in this campaign.') + .addStringOption((option) => + option.setName('campaign').setDescription('Campaign to search for').setAutocomplete(true), + ), + new SlashCommandBuilder().setName('quest').setDescription('Complete a quest and earn points.'), + new SlashCommandBuilder().setName('buy').setDescription('Buy a reward with points.'), + new SlashCommandBuilder() + .setName('remove-points') + .setDescription('Remove an amount of points for a user.') + .addUserOption((option) => + option.setName('user').setDescription('The user to transfer points to').setRequired(true), + ) + .addIntegerOption((option) => + option.setName('amount').setDescription('The amount of points to transfer').setRequired(true), + ) + .addStringOption((option) => + option.setName('campaign').setDescription('Campaign to search for').setAutocomplete(true), + ) + .addStringOption((option) => + option.setName('secret').setDescription('The optional secret for increased security'), + ), + new SlashCommandBuilder() + .setName('give-points') + .setDescription('Give an amount of points to a user.') + .addUserOption((option) => + option.setName('user').setDescription('The user to transfer points to').setRequired(true), + ) + .addIntegerOption((option) => + option.setName('amount').setDescription('The amount of points to transfer').setRequired(true), + ) + .addStringOption((option) => + option.setName('campaign').setDescription('Campaign to search for').setAutocomplete(true), + ) + .addStringOption((option) => + option.setName('secret').setDescription('The optional secret for increased security'), + ), +]; + +export default { + data: commands, + executor: (interaction: CommandInteraction) => { + const commandMap = { + 'quest': () => onSubcommandComplete(interaction), + 'buy': () => onSubcommandBuy(interaction), + 'info': () => onSubcommandInfo(interaction), + 'give-points': () => onSubcommandPoints(interaction, DiscordCommandVariant.GivePoints), + 'remove-points': () => onSubcommandPoints(interaction, DiscordCommandVariant.RemovePoints), + }; + const command = interaction.commandName; + if (commandMap[command]) commandMap[command](); + }, +}; diff --git a/apps/api/src/app/events/commands/thx/buy.ts b/apps/api/src/app/events/commands/thx/buy.ts new file mode 100644 index 000000000..23e87845c --- /dev/null +++ b/apps/api/src/app/events/commands/thx/buy.ts @@ -0,0 +1,18 @@ +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import { createSelectMenuRewards } from '@thxnetwork/api/events/components'; +import { CommandInteraction } from 'discord.js'; +import { handleError } from '../error'; + +export const onSubcommandBuy = async (interaction: CommandInteraction) => { + try { + const account = await AccountProxy.getByDiscordId(interaction.user.id); + if (!account) throw new Error('Please, connect your THX Account with Discord first.'); + + const row = await createSelectMenuRewards(interaction.guild); + + interaction.reply({ components: [row as any], ephemeral: true }); + } catch (error) { + handleError(error, interaction); + } +}; +export default { onSubcommandBuy }; diff --git a/apps/api/src/app/events/commands/thx/index.ts b/apps/api/src/app/events/commands/thx/index.ts new file mode 100644 index 000000000..2a08d413f --- /dev/null +++ b/apps/api/src/app/events/commands/thx/index.ts @@ -0,0 +1,4 @@ +export * from './quest'; +export * from './buy'; +export * from './points'; +export * from './info'; diff --git a/apps/api/src/app/events/commands/thx/info.ts b/apps/api/src/app/events/commands/thx/info.ts new file mode 100644 index 000000000..1cbcba9f5 --- /dev/null +++ b/apps/api/src/app/events/commands/thx/info.ts @@ -0,0 +1,85 @@ +import { ButtonStyle, CommandInteraction, Embed } from 'discord.js'; +import { Participant, Widget, Brand, Pool } from '@thxnetwork/api/models'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import DiscordDataProxy from '@thxnetwork/api/proxies/DiscordDataProxy'; +import { DiscordButtonVariant } from '../../InteractionCreated'; +import { handleError } from '../error'; +import { getDiscordGuild } from './points'; + +export const onSubcommandInfo = async (interaction: CommandInteraction) => { + try { + const account = await AccountProxy.getByDiscordId(interaction.user.id); + if (!account) throw new Error('Please, connect your Discord.'); + + const { discordGuild, error } = await getDiscordGuild(interaction); + if (error) throw new Error(error); + + const pool = await Pool.findById(discordGuild.poolId); + if (!pool) throw new Error('Could not find connected campaign.'); + + const participant = await Participant.findOne({ poolId: pool._id, sub: account.sub }); + if (!participant) throw new Error('You have not participated in the campaign yet.'); + + const brand = await Brand.findOne({ poolId: pool._id }); + const widget = await Widget.findOne({ poolId: pool._id }); + const theme = JSON.parse(widget.theme); + const color = parseInt(theme.elements.btnBg.color.replace(/^#/, ''), 16); + + const row = DiscordDataProxy.createButtonActionRow([ + { + style: ButtonStyle.Primary, + label: 'Quests', + customId: DiscordButtonVariant.QuestList, + emoji: `✅`, + }, + { + style: ButtonStyle.Primary, + label: 'Rewards', + customId: DiscordButtonVariant.RewardList, + emoji: `🎁`, + }, + { + style: ButtonStyle.Link, + label: 'Campaign URL', + url: `${pool.campaignURL}`, + }, + ]); + + const embed: any = { + title: `${pool.settings.title}`, + description: pool.settings.description ? `${pool.settings.description}` : ` `, + color, + fields: [ + { + name: `Name`, + value: `${account.username}`, + }, + { + name: `Points`, + value: participant ? `${participant.balance}` : '0', + inline: true, + }, + { + name: `Rank`, + value: participant && participant.rank > 0 ? `#${participant.rank}` : 'None', + inline: true, + }, + ], + } as Embed; + + if (brand && brand.backgroundImgUrl) { + embed['image'] = { url: brand && brand.backgroundImgUrl }; + } + + if (brand && brand.logoImgUrl) { + embed['thumbnail'] = { + url: brand.logoImgUrl, + }; + } + + interaction.reply({ embeds: [embed], components: [row as any], ephemeral: true }); + } catch (error) { + handleError(error, interaction); + } +}; +export default { onSubcommandInfo }; diff --git a/apps/api/src/app/events/commands/thx/points.ts b/apps/api/src/app/events/commands/thx/points.ts new file mode 100644 index 000000000..55173f2cc --- /dev/null +++ b/apps/api/src/app/events/commands/thx/points.ts @@ -0,0 +1,141 @@ +import { ButtonInteraction, CommandInteraction, User } from 'discord.js'; +import { WIDGET_URL } from '@thxnetwork/api/config/secrets'; +import { handleError } from '../error'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import PointBalanceService from '@thxnetwork/api/services/PointBalanceService'; +import { PoolDocument, Participant, DiscordGuild, Pool } from '@thxnetwork/api/models'; + +export enum DiscordCommandVariant { + GivePoints = 0, + RemovePoints = 1, +} + +async function removePoints( + pool: PoolDocument, + sender: TAccount, + receiver: TAccount, + senderUser: User, + receiverUser: User, + amount: number, +) { + await PointBalanceService.subtract(pool, receiver, amount); + + const participant = await Participant.findOne({ + poolId: pool._id, + sub: receiver.sub, + }); + + const senderMessage = `The balance of <@${receiverUser.id}> has been decreased with **${amount} points** and is now **${participant.balance}**.`; + const receiverMessage = `<@${senderUser.id}> decreased your balance with **${amount}** resulting in a total of **${participant.balance} points**.`; + + return { senderMessage, receiverMessage }; +} + +async function addPoints( + pool: PoolDocument, + sender: TAccount, + receiver: TAccount, + senderUser: User, + receiverUser: User, + amount: number, +) { + await PointBalanceService.add(pool, receiver, amount); + + const participant = await Participant.findOne({ + poolId: pool._id, + sub: receiver.sub, + }); + const senderMessage = `The balance of <@${receiverUser.id}> has been increased with **${amount} points** and is now **${participant.balance}**!`; + const receiverMessage = `<@${senderUser.id}> increased your balance with **${amount}** resulting in a total of **${participant.balance} points**.`; + + return { senderMessage, receiverMessage }; +} + +const pointsFunctionMap = { + [DiscordCommandVariant.GivePoints]: addPoints, + [DiscordCommandVariant.RemovePoints]: removePoints, +}; + +export async function getDiscordGuild(interaction: CommandInteraction | ButtonInteraction) { + const discordGuilds = await DiscordGuild.find({ guildId: interaction.guild.id }); + if (!discordGuilds.length) return { error: 'No campaign found ' }; + if (discordGuilds.length === 1) return { discordGuild: discordGuilds[0] }; + + const choice = ((interaction as CommandInteraction).options as any).getString('campaign'); + if (!choice) return { error: 'Please, select a campaign for this command.' }; + + const campaign = await Pool.findOne({ 'settings.title': choice }); + if (!campaign) return { error: 'Could not find campaing for this choice.' }; + + const discordGuild = discordGuilds.find((g) => g.poolId === String(campaign._id)); + return { discordGuild }; +} + +export const onSubcommandPoints = async (interaction: CommandInteraction, variant: DiscordCommandVariant) => { + try { + const account = await AccountProxy.getByDiscordId(interaction.user.id); + if (!account) throw new Error('Please, connect your THX Account with Discord first.'); + + const query = interaction.options.get('user').value as string; + if (!query) throw new Error('Please, provide a valid username.'); + + const result = await interaction.guild.members.search({ query, limit: 1 }); + if (!result) throw new Error('Could not find user'); + if (!result[query]) throw new Error('Could not find user'); + const user = result[query]; + if (!result[query]) throw new Error('Could not find user in search result'); + + const { discordGuild, error } = await getDiscordGuild(interaction); + if (error) throw new Error(error); + + // Check optional secret + const secret = interaction.options.get('secret'); + if (discordGuild.secret && discordGuild.secret.length) { + if (!secret) throw new Error('Please, provide a secret.'); + if (discordGuild.secret !== secret.value) throw new Error('Please, provide a valid secret.'); + } + + // Check role + const member = await interaction.guild.members.fetch(interaction.user.id); + if (!member.roles.cache.has(discordGuild.adminRoleId)) { + const role = await interaction.guild.roles.fetch(discordGuild.adminRoleId); + throw new Error(`Only **${role.name}** roles have access to this command!`); + } + + const amount = interaction.options.get('amount'); + if (!amount.value || Number(amount.value) < 1) throw new Error('Please, provide a valid amount.'); + + const pool = await Pool.findById(discordGuild.poolId); + if (!pool) throw new Error('Could not find connected campaign.'); + + const receiver = await AccountProxy.getByDiscordId(user.id); + if (!receiver) { + user.send({ + content: `<@${interaction.user.id}> failed to send you ${amount.value} points. Please [sign in](${WIDGET_URL}/c/${pool._id}), connect Discord and notify the sender!`, + }); + throw new Error('Please, ask the receiver to connect a Discord account.'); + } + + // Determine if we should add or remove using pointsFunctionMap + const { senderMessage, receiverMessage } = await pointsFunctionMap[variant]( + pool, + account, + receiver, + interaction.user, + user, + Number(amount.value), + ); + + // Send reaction to caller + interaction.reply({ + content: senderMessage, + ephemeral: true, + }); + + // Send DM to user + user.send({ content: receiverMessage }); + } catch (error) { + handleError(error, interaction); + } +}; +export default { onSubcommandPoints }; diff --git a/apps/api/src/app/events/commands/thx/quest.ts b/apps/api/src/app/events/commands/thx/quest.ts new file mode 100644 index 000000000..5c969579f --- /dev/null +++ b/apps/api/src/app/events/commands/thx/quest.ts @@ -0,0 +1,21 @@ +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import { createSelectMenuQuests } from '@thxnetwork/api/events/components'; +import { CommandInteraction } from 'discord.js'; +import { handleError } from '../error'; + +export const onSubcommandComplete = async (interaction: CommandInteraction) => { + try { + const account = await AccountProxy.getByDiscordId(interaction.user.id); + if (!account) throw new Error('Please, connect your THX Account with Discord first.'); + + const row = await createSelectMenuQuests(interaction); + if (!row) { + interaction.reply({ content: 'No quests found for this campaign.', ephemeral: true }); + } else { + interaction.reply({ components: [row as any], ephemeral: true }); + } + } catch (error) { + handleError(error, interaction); + } +}; +export default { onSubcommandComplete }; diff --git a/apps/api/src/app/events/components/index.ts b/apps/api/src/app/events/components/index.ts new file mode 100644 index 000000000..363a3f634 --- /dev/null +++ b/apps/api/src/app/events/components/index.ts @@ -0,0 +1,2 @@ +export * from './selectMenuQuests'; +export * from './selectMenuRewards'; diff --git a/apps/api/src/app/events/components/selectMenuQuests.ts b/apps/api/src/app/events/components/selectMenuQuests.ts new file mode 100644 index 000000000..35c534d10 --- /dev/null +++ b/apps/api/src/app/events/components/selectMenuQuests.ts @@ -0,0 +1,75 @@ +import { + ActionRowBuilder, + ButtonInteraction, + CommandInteraction, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, +} from 'discord.js'; +import { DiscordStringSelectMenuVariant } from '../InteractionCreated'; +import { QuestInvite } from '@thxnetwork/api/models/QuestInvite'; +import { QuestWeb3 } from '@thxnetwork/api/models/QuestWeb3'; +import { questInteractionVariantMap } from '@thxnetwork/common/maps'; +import QuestService from '@thxnetwork/api/services/QuestService'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import { + DiscordGuild, + Pool, + PoolDocument, + QuestCustom, + QuestDaily, + QuestGitcoin, + QuestSocial, +} from '@thxnetwork/api/models'; + +async function findQuests(campaigns: PoolDocument[]) { + const poolId = campaigns.map(({ _id }) => String(_id)); + return await Promise.all([ + QuestDaily.find({ poolId, isPublished: true }), + QuestInvite.find({ poolId, isPublished: true }), + QuestSocial.find({ poolId, isPublished: true }), + QuestCustom.find({ poolId, isPublished: true }), + QuestWeb3.find({ poolId, isPublished: true }), + QuestGitcoin.find({ poolId, isPublished: true }), + ]); +} + +async function createSelectMenuQuests(interaction: CommandInteraction | ButtonInteraction) { + const discordGuilds = await DiscordGuild.find({ guildId: interaction.guild.id }); + if (!discordGuilds.length) throw new Error('Could not find server.'); + + const poolId = discordGuilds.map((g) => g.poolId); + const campaigns = await Pool.find({ _id: poolId }); + if (!campaigns.length) throw new Error('No campaigns found for this server.'); + + const select = new StringSelectMenuBuilder(); + select.setCustomId(DiscordStringSelectMenuVariant.QuestComplete).setPlaceholder('Complete a quest'); + + const account = await AccountProxy.getByDiscordId(interaction.user.id); + if (!account) throw new Error('No THX account found for this Discord user.'); + + const quests = (await findQuests(campaigns)).flat(); + if (!quests.length) throw new Error('No quests found for this campaign.'); + + for (const index in quests) { + const quest: any = quests[index]; + + // Campaign might be removed + const campaign = campaigns.find((c) => String(c._id) === quest.poolId); + if (!campaign) continue; + + const questId = String(quest._id); + const variant = quest.interaction ? questInteractionVariantMap[quest.interaction] : quest.variant; + const value = JSON.stringify({ questId, variant }); + const amount = await QuestService.getAmount(variant, quest, account); + const options = new StringSelectMenuOptionBuilder() + .setLabel(`[${amount}] ${quest.title}`) + .setDescription(`${campaign.settings.title}`) + .setValue(value); + + select.addOptions(options); + } + + return new ActionRowBuilder().addComponents(select); +} + +export { createSelectMenuQuests }; diff --git a/apps/api/src/app/events/components/selectMenuRewards.ts b/apps/api/src/app/events/components/selectMenuRewards.ts new file mode 100644 index 000000000..397f3ff7c --- /dev/null +++ b/apps/api/src/app/events/components/selectMenuRewards.ts @@ -0,0 +1,43 @@ +import { RewardVariant } from '@thxnetwork/common/enums'; +import { ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Guild } from 'discord.js'; +import { DiscordStringSelectMenuVariant } from '../InteractionCreated'; +import { + DiscordGuild, + RewardNFT, + RewardDiscordRole, + RewardCoin, + RewardCustom, + RewardCoupon, +} from '@thxnetwork/api/models'; + +async function createSelectMenuRewards(guild: Guild) { + const { poolId } = await DiscordGuild.findOne({ guildId: guild.id }); + const results = await Promise.all([ + RewardCoin.find({ poolId, pointPrice: { $gt: 0 } }), + RewardNFT.find({ poolId, pointPrice: { $gt: 0 } }), + RewardCustom.find({ poolId, pointPrice: { $gt: 0 } }), + RewardCoupon.find({ poolId, pointPrice: { $gt: 0 } }), + RewardDiscordRole.find({ poolId, pointPrice: { $gt: 0 } }), + ]); + const rewards = results.flat(); + if (!rewards.length) throw new Error('No rewards found for this campaign.'); + + const select = new StringSelectMenuBuilder(); + select.setCustomId(DiscordStringSelectMenuVariant.RewardBuy).setPlaceholder('Buy a reward'); + + for (const index in rewards) { + const reward = rewards[index]; + const questId = String(reward._id); + const value = JSON.stringify({ questId, variant: reward.variant }); + const options = new StringSelectMenuOptionBuilder() + .setLabel(reward.title) + .setDescription(`${reward.pointPrice} points (${RewardVariant[reward.variant]} Reward)`) + .setValue(value); + + select.addOptions(options); + } + + return new ActionRowBuilder().addComponents(select); +} + +export { createSelectMenuRewards }; diff --git a/apps/api/src/app/events/handlers/button/quest.ts b/apps/api/src/app/events/handlers/button/quest.ts new file mode 100644 index 000000000..315c3ea10 --- /dev/null +++ b/apps/api/src/app/events/handlers/button/quest.ts @@ -0,0 +1,28 @@ +import { ButtonInteraction } from 'discord.js'; +import { QuestVariant } from '@thxnetwork/common/enums'; +import { completeQuest } from '@thxnetwork/api/events/handlers/select/quest'; +import { handleError } from '../../commands/error'; +import { createSelectMenuQuests } from '../../components'; + +export async function onClickQuestComplete(interaction: ButtonInteraction) { + try { + // Custom Id Syntax: DiscordButtonVariant.QuestComplete + ':' + questVariant ':' + questId + const data = interaction.customId.split(':'); + const variant = data[1] as unknown as QuestVariant; + const questId = data[2]; + + await completeQuest(interaction, variant, questId); + } catch (error) { + handleError(error, interaction); + } +} + +export async function onClickQuestList(interaction: ButtonInteraction) { + try { + const row = await createSelectMenuQuests(interaction); + + interaction.reply({ components: [row as any], ephemeral: true }); + } catch (error) { + handleError(error, interaction); + } +} diff --git a/apps/api/src/app/events/handlers/button/reward.ts b/apps/api/src/app/events/handlers/button/reward.ts new file mode 100644 index 000000000..c88234877 --- /dev/null +++ b/apps/api/src/app/events/handlers/button/reward.ts @@ -0,0 +1,60 @@ +import { ButtonInteraction } from 'discord.js'; +import { RewardVariant } from '@thxnetwork/common/enums'; +import { handleError } from '../../commands/error'; +import { + RewardCoin, + RewardNFT, + DiscordGuild, + RewardCustom, + RewardCoupon, + RewardDiscordRole, +} from '@thxnetwork/api/models'; + +export async function onClickRewardRedeem(interaction: ButtonInteraction) { + try { + // Custom Id Syntax: DiscordButtonVariant.QuestComplete + ':' + questVariant ':' + questId + const data = interaction.customId.split(':'); + const variant = data[1] as unknown as RewardVariant; + const questId = data[2]; + + // await completeReward(interaction, variant, questId); + } catch (error) { + handleError(error, interaction); + } +} + +export async function onClickRewardList(interaction: ButtonInteraction) { + try { + const discordGuild = await DiscordGuild.findOne({ guildId: interaction.guild.id }); + if (!discordGuild) throw new Error('Could not find this Discord server.'); + + const { poolId } = discordGuild; + const results = await Promise.all([ + RewardCoin.find({ poolId, pointPrice: { $gt: 0 } }), + RewardNFT.find({ poolId, pointPrice: { $gt: 0 } }), + RewardCustom.find({ poolId, pointPrice: { $gt: 0 } }), + RewardCoupon.find({ poolId, pointPrice: { $gt: 0 } }), + RewardDiscordRole.find({ poolId, pointPrice: { $gt: 0 } }), + ]); + const rewards = results.flat(); + if (!rewards.length) throw new Error('No rewards found for this campaign.'); + + const list = rewards.map( + (reward: any) => + `${String(reward.pointPrice).padStart(4)} pts. ${reward.title} (${RewardVariant[reward.variant]})`, + ); + const code = list.join('\n'); + const embeds = [ + { + title: `🎁 Rewards`, + description: rewards.length + ? 'Use `/buy` to buy rewards with points. \n ```' + code + `\n` + '```' + : 'No rewards available!', + }, + ]; + + interaction.reply({ embeds, ephemeral: true }); + } catch (error) { + handleError(error, interaction); + } +} diff --git a/apps/api/src/app/events/handlers/index.ts b/apps/api/src/app/events/handlers/index.ts new file mode 100644 index 000000000..280d715e4 --- /dev/null +++ b/apps/api/src/app/events/handlers/index.ts @@ -0,0 +1,3 @@ +export * from './select/quest'; +export * from './button/quest'; +export * from './button/reward'; diff --git a/apps/api/src/app/events/handlers/select/quest.ts b/apps/api/src/app/events/handlers/select/quest.ts new file mode 100644 index 000000000..3ef04e548 --- /dev/null +++ b/apps/api/src/app/events/handlers/select/quest.ts @@ -0,0 +1,138 @@ +import { ButtonInteraction, ButtonStyle, StringSelectMenuInteraction } from 'discord.js'; +import { JobType, QuestVariant } from '@thxnetwork/common/enums'; +import { handleError } from '../../commands/error'; +import { DiscordButtonVariant } from '../../InteractionCreated'; +import { Widget, Brand } from '@thxnetwork/api/models'; +import { agenda } from '@thxnetwork/api/util/agenda'; +import { DiscordDisconnected } from '@thxnetwork/api/util/errors'; +import { serviceMap } from '@thxnetwork/api/services/interfaces/IQuestService'; +import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import QuestService from '@thxnetwork/api/services/QuestService'; +import DiscordDataProxy from '@thxnetwork/api/proxies/DiscordDataProxy'; + +export async function completeQuest( + interaction: ButtonInteraction | StringSelectMenuInteraction, + variant: QuestVariant, + questId: string, +) { + try { + const account = await AccountProxy.getByDiscordId(interaction.user.id); + if (!account) throw new DiscordDisconnected(); + + const Quest = serviceMap[variant].models.quest; + const quest = await Quest.findById(questId); + if (!quest) throw new Error('Could not find this quest.'); + + const pool = await PoolService.getById(quest.poolId); + if (!pool) throw new Error('Could not find this campaign.'); + + const { interaction: questInteraction } = quest as TQuestSocial; + const platformUserId = questInteraction && QuestService.findUserIdForInteraction(account, questInteraction); + + const data = { + isClaimed: true, + platformUserId, + }; + const availabilityValidation = await QuestService.isAvailable(variant, { + quest, + account, + data, + }); + if (!availabilityValidation.result) throw new Error(availabilityValidation.reason); + + const requirementValidation = await QuestService.getValidationResult(variant, { + quest, + account, + data, + }); + if (!requirementValidation.result) throw new Error(requirementValidation.reason); + + const amount = await QuestService.getAmount(variant, quest, account); + + await agenda.now(JobType.CreateQuestEntry, { + variant, + questId: quest._id, + sub: account.sub, + data, + }); + + interaction.reply({ + content: `Completed **${quest.title}** and earned **${amount} points**.`, + ephemeral: true, + }); + } catch (error) { + handleError(error, interaction); + } +} + +export async function onSelectQuestComplete(interaction: StringSelectMenuInteraction) { + try { + const { questId, variant } = JSON.parse(interaction.values[0]); + + const account = await AccountProxy.getByDiscordId(interaction.user.id); + if (!account) throw new DiscordDisconnected(); + + const quest = await QuestService.findById(variant, questId); + if (!quest) throw new Error('Could not find this quest.'); + + const pool = await PoolService.getById(quest.poolId); + if (!pool) throw new Error('Could not find this campaign.'); + + const data = {}; + const isAvailable = await QuestService.isAvailable(variant, { quest, account, data }); + const brand = await Brand.findOne({ poolId: pool._id }); + const widget = await Widget.findOne({ poolId: pool._id }); + const theme = JSON.parse(widget.theme); + const amount = await QuestService.getAmount(quest.variant, quest, account); + const embedQuest = { + title: quest.title, + description: quest.description, + author: { + name: pool.settings.title, + icon_url: brand ? brand.logoImgUrl : '', + url: widget.domain, + }, + image: { url: quest.image }, + color: parseInt(theme.elements.btnBg.color.replace(/^#/, ''), 16), + fields: [ + { + name: 'Points', + value: `${amount}`, + inline: true, + }, + { + name: 'Type', + value: `${QuestVariant[quest.variant]}`, + inline: true, + }, + ], + }; + + const row = DiscordDataProxy.createButtonActionRow([ + { + emoji: isAvailable ? '✅' : '🔒', + label: 'Complete', + style: ButtonStyle.Success, + customId: `${DiscordButtonVariant.QuestComplete}:${variant}:${questId}`, + disabled: !isAvailable, + }, + { + label: 'More Info', + style: ButtonStyle.Link, + url: `${pool.campaignURL}/quests`, + }, + ]); + const components = []; + components.push(row); + + interaction.reply({ + ephemeral: true, + content: '', + embeds: [embedQuest], + components, + }); + } catch (error) { + handleError(error, interaction); + } +} diff --git a/apps/api/src/app/events/index.ts b/apps/api/src/app/events/index.ts new file mode 100644 index 000000000..14fd91d46 --- /dev/null +++ b/apps/api/src/app/events/index.ts @@ -0,0 +1,16 @@ +import { Events } from 'discord.js'; +import onClientReady from './ClientReady'; +import onInteractionCreated from './InteractionCreated'; +import onMessageReactionAdd from './MessageReactionAdd'; +import onMessageCreate from './MessageCreate'; +import onGuildCreate from './GuildCreate'; +import onGuildDelete from './GuildDelete'; + +export default { + [Events.ClientReady]: onClientReady, + [Events.GuildCreate]: onGuildCreate, + [Events.GuildDelete]: onGuildDelete, + [Events.InteractionCreate]: onInteractionCreated, + [Events.MessageReactionAdd]: onMessageReactionAdd, + [Events.MessageCreate]: onMessageCreate, +}; diff --git a/apps/api/src/app/index.ts b/apps/api/src/app/index.ts new file mode 100644 index 000000000..fac02b9bb --- /dev/null +++ b/apps/api/src/app/index.ts @@ -0,0 +1,53 @@ +import 'express-async-errors'; +import axios from 'axios'; +import axiosBetterStacktrace from 'axios-better-stacktrace'; +import compression from 'compression'; +import express, { Express, Request } from 'express'; +import lusca from 'lusca'; +import db from '@thxnetwork/api/util/database'; +import morganBody from 'morgan-body'; +import { router } from '@thxnetwork/api/controllers/index'; +import { MONGODB_URI, NODE_ENV, PORT, VERSION } from '@thxnetwork/api/config/secrets'; +import { corsHandler, errorLogger, errorNormalizer, errorOutput, notFoundHandler } from '@thxnetwork/api/middlewares'; +import { assetsPath } from './util/path'; +import morgan from './middlewares/morgan'; + +axiosBetterStacktrace(axios); + +const app: Express = express(); + +db.connect(MONGODB_URI); + +app.set('trust proxy', true); +app.set('port', PORT); +app.use(lusca.xframe('SAMEORIGIN')); +app.use(lusca.xssProtection(true)); +app.use(express.static(assetsPath)); +app.use( + express.json({ + verify(req: Request, res, buf, encoding: BufferEncoding) { + if (buf && buf.length) { + req.rawBody = buf.toString(encoding || 'utf8'); + } + }, + }), +); + +app.use(morgan); + +morganBody(app, { + logRequestBody: NODE_ENV === 'development', + logResponseBody: false, + skip: () => ['test', 'production'].includes(NODE_ENV), +}); + +app.use(express.urlencoded({ extended: true })); +app.use(corsHandler); +app.use(`/${VERSION}`, router); +app.use(notFoundHandler); +app.use(errorLogger); +app.use(errorNormalizer); +app.use(errorOutput); +app.use(compression()); + +export default app; diff --git a/apps/api/src/app/jobs/createTwitterQuests.ts b/apps/api/src/app/jobs/createTwitterQuests.ts new file mode 100644 index 000000000..27e02c0b3 --- /dev/null +++ b/apps/api/src/app/jobs/createTwitterQuests.ts @@ -0,0 +1,99 @@ +import { AccessTokenKind, QuestVariant, QuestSocialRequirement, OAuthRequiredScopes } from '@thxnetwork/common/enums'; +import { Pool, QuestSocial } from '@thxnetwork/api/models'; +import { logger } from '../util/logger'; +import { DASHBOARD_URL } from '../config/secrets'; +import TwitterDataProxy from '../proxies/TwitterDataProxy'; +import AccountProxy from '../proxies/AccountProxy'; +import MailService from '../services/MailService'; +import QuestService from '../services/QuestService'; + +export async function createTwitterQuests() { + for await (const pool of Pool.find({ 'settings.isTwitterSyncEnabled': true })) { + try { + const { hashtag, title, description, amount, locks, isPublished } = + pool.settings.defaults.conditionalRewards; + + const account = await AccountProxy.findById(pool.sub); + if (!account) { + logger.error(`Account not found for ${pool.sub}.`); + continue; + } + + const token = await AccountProxy.getToken( + account, + AccessTokenKind.Twitter, + OAuthRequiredScopes.TwitterAutoQuest, + ); + if (!token) { + logger.error(`Could not find Twitter accounts for ${pool.sub} in ${pool.settings.title}`); + continue; + } + + const tweets = await TwitterDataProxy.searchTweets(account, `#${hashtag}`); + if (!tweets || !tweets.length) continue; + logger.info(`Found tweets matching the hashtag in the last 7 days!`); + logger.info(JSON.stringify(tweets)); + + const promises = tweets.map(async (tweet: any) => { + const isExistingQuest = !!(await QuestSocial.exists({ + poolId: String(pool._id), + content: tweet.id, + })); + return { ...tweet, isExistingQuest }; + }); + const recentTweets = await Promise.all(promises); + logger.info(`Found ${recentTweets.length} posts for ${pool.sub} in ${pool.settings.title}`); + + const newTweets = recentTweets.filter( + (tweet) => !tweet.isExistingQuest && tweet.text.includes(`#${hashtag}`), + ); + if (!newTweets.length) { + logger.info(`Found no new autoquests for ${pool.sub} in ${pool.settings.title}`); + continue; + } + logger.info(`Found ${newTweets.length} new autoquest for ${pool.sub} in ${pool.settings.title}`); + + const quests = await Promise.all( + newTweets.map(async (tweet) => { + try { + const contentMetadata = JSON.stringify({ + url: `https://twitter.com/${token.metadata.username}/status/${tweet.id}`, + username: token.metadata.username, + text: tweet.text, + minFollowersCount: 0, + }); + return await QuestService.create(QuestVariant.Twitter, pool._id, { + index: 0, + title, + description, + amount, + locks, + kind: AccessTokenKind.Twitter, + interaction: QuestSocialRequirement.TwitterLikeRetweet, + content: tweet.id, + contentMetadata, + isPublished, + }); + } catch (error) { + logger.error(error.message); + } + }), + ); + + const subject = `Created ${quests.length} Twitter Quest${quests.length && 's'}!`; + const message = `We have detected ${quests.length} new tweet${ + quests.length && 's' + } in @${ + token.metadata.username + }. A Twitter Quest ${quests.length && 'for each'} has been ${ + isPublished ? 'published' : 'prepared' + } for you in ${pool.settings.title}.`; + + await MailService.send(account.email, subject, message); + + logger.info(`Created ${quests.length} Twitter Quests in ${pool.settings.title}`); + } catch (error) { + logger.info(error); + } + } +} diff --git a/apps/api/src/app/jobs/sendPoolAnalyticsReport.ts b/apps/api/src/app/jobs/sendPoolAnalyticsReport.ts new file mode 100644 index 000000000..ad1c04109 --- /dev/null +++ b/apps/api/src/app/jobs/sendPoolAnalyticsReport.ts @@ -0,0 +1,110 @@ +import { Pool } from '@thxnetwork/api/models'; +import { DASHBOARD_URL } from '../config/secrets'; +import { logger } from '../util/logger'; +import PoolService from '../services/PoolService'; +import AccountProxy from '../proxies/AccountProxy'; +import MailService from '../services/MailService'; +import AnalyticsService from '../services/AnalyticsService'; + +const emojiMap = ['🥇', '🥈', '🥉']; +const oneDay = 86400000; // one day in milliseconds + +export async function sendPoolAnalyticsReport() { + const endDate = new Date(); + endDate.setHours(0, 0, 0, 0); + + const startDate = new Date(new Date(endDate).getTime() - oneDay * 7); + const dateRange = { startDate, endDate }; + + let account: TAccount; + + for await (const pool of Pool.find({ 'settings.isWeeklyDigestEnabled': true })) { + try { + if (!account || account.sub != pool.sub) account = await AccountProxy.findById(pool.sub); + if (!account.email) continue; + + const { dailyQuest, inviteQuest, socialQuest, customQuest, coinReward, nftReward } = + await AnalyticsService.getPoolMetrics(pool, dateRange); + const leaderboard = await PoolService.findParticipants(pool, 1, 10); + const subs = leaderboard.results.map((entry) => entry.sub); + const accounts = await AccountProxy.find({ subs }); + + const totalPointsClaimed = + dailyQuest.totalAmount + inviteQuest.totalAmount + socialQuest.totalAmount + customQuest.totalAmount; + const totalPointsSpent = coinReward.totalAmount + nftReward.totalAmount; + + // Skip if nothing happened. + if (!totalPointsClaimed && !totalPointsSpent) continue; + + let html = `

Hi there!👋

`; + html += `

We're pleased to bring you the Weekly Digest for "${pool.settings.title}".

`; + html += `
`; + + html += `

🏆 Quests: ${totalPointsClaimed} points claimed

`; + html += ``; + if (dailyQuest.totalCreated) { + html += ` + + + `; + } + if (inviteQuest.totalCreated) { + html += ` + + + `; + } + if (socialQuest.totalCreated) { + html += ` + + + `; + } + if (customQuest.totalCreated) { + html += ` + + + `; + } + html += `
${dailyQuest.totalCreated}x Daily - ${dailyQuest.totalAmount} ptsManage
${inviteQuest.totalCreated}x Invite - ${inviteQuest.totalAmount} pts)Manage
${socialQuest.totalCreated}x Social - ${socialQuest.totalAmount} ptsManage
${customQuest.totalCreated}x Custom - ${customQuest.totalAmount} ptsManage
`; + html += `
`; + + html += `

🎁 Rewards: ${totalPointsSpent} points spent

`; + html += ``; + if (coinReward.totalCreated) { + html += ` + + + `; + } + if (nftReward.totalCreated) { + html += ` + + + `; + } + html += `
${coinReward.totalCreated}x Coin Rewards (${coinReward.totalAmount} points)Manage
${nftReward.totalCreated}x NFT Rewards (${nftReward.totalAmount} points)Manage
`; + html += `
`; + + html += `

Top 3

`; + html += ``; + + for (const index in leaderboard.results) { + const entry = leaderboard[index]; + const account = accounts.find((a) => a.sub === entry.sub); + + html += ` + + + + `; + } + html += '
${emojiMap[index]}${account.firstName || '...'} ${entry.wallet.address.substring(0, 8)}...${entry.score} Points
'; + html += `All participants`; + + await MailService.send(account.email, `🎁 Weekly Digest: "${pool.settings.title}"`, html); + } catch (error) { + logger.error(error); + } + } +} diff --git a/apps/api/src/app/jobs/updateCampaignRanks.ts b/apps/api/src/app/jobs/updateCampaignRanks.ts new file mode 100644 index 000000000..a34e1fe71 --- /dev/null +++ b/apps/api/src/app/jobs/updateCampaignRanks.ts @@ -0,0 +1,100 @@ +import { + Pool, + RewardCoin, + RewardNFT, + RewardCustom, + RewardCoupon, + RewardDiscordRole, + RewardGalachain, + QuestDaily, + QuestInvite, + QuestSocial, + QuestCustom, + QuestWeb3, + QuestGitcoin, + Participant, +} from '@thxnetwork/api/models'; +import { logger } from '../util/logger'; + +export async function updateCampaignRanks() { + try { + const questModels = [QuestDaily, QuestInvite, QuestSocial, QuestCustom, QuestWeb3, QuestGitcoin]; + const rewardModels = [RewardCoin, RewardNFT, RewardCustom, RewardCoupon, RewardDiscordRole, RewardGalachain]; + const questLookupStages = questModels.map((model) => { + return { + $lookup: { + from: model.collection.name, + localField: 'id', + foreignField: 'poolId', + as: model.collection.name, + }, + }; + }); + const rewardLookupStages = rewardModels.map((model) => { + return { + $lookup: { + from: model.collection.name, + localField: 'id', + foreignField: 'poolId', + as: model.collection.name, + }, + }; + }); + const campaigns = await Pool.aggregate([ + { + $addFields: { + id: { $toString: '$_id' }, + }, + }, + { + $lookup: { + from: Participant.collection.name, + localField: 'id', + foreignField: 'poolId', + as: Participant.collection.name, + }, + }, + // Rewards + ...questLookupStages, + ...rewardLookupStages, + { + $addFields: { + participantCount: { $size: `$${Participant.collection.name}` }, + totalQuestCount: { + $size: { + $concatArrays: questModels.map((model) => `$${model.collection.name}`), + }, + }, + totalRewardsCount: { + $size: { + $concatArrays: rewardModels.map((model) => `$${model.collection.name}`), + }, + }, + }, + }, + { + $match: { + 'settings.isPublished': true, + 'totalQuestCount': { $gt: 0 }, + 'totalRewardsCount': { $gt: 0 }, + }, + }, + { + $sort: { participantCount: -1 }, + }, + ]).exec(); + + await Pool.bulkWrite( + campaigns.map((campaign, index) => { + return { + updateOne: { + filter: { _id: campaign._id }, + update: { $set: { rank: Number(index) + 1 } }, + }, + }; + }), + ); + } catch (error) { + logger.error(error); + } +} diff --git a/apps/api/src/app/jobs/updateParticipantRanks.ts b/apps/api/src/app/jobs/updateParticipantRanks.ts new file mode 100644 index 000000000..94da0c021 --- /dev/null +++ b/apps/api/src/app/jobs/updateParticipantRanks.ts @@ -0,0 +1,20 @@ +import { Pool } from '@thxnetwork/api/models'; +import { logger } from '../util/logger'; +import { Job } from '@hokify/agenda'; +import AnalyticsService from '../services/AnalyticsService'; + +export async function updateParticipantRanks(job: Job) { + if (!job.attrs.data) return; + + try { + const { poolId } = job.attrs.data as { poolId: string }; + const pool = await Pool.findById(poolId); + if (!pool) throw new Error('Could not find campaign'); + + await AnalyticsService.createLeaderboard(pool); + + logger.info('Updated participant ranks.'); + } catch (error) { + logger.error(error); + } +} diff --git a/apps/api/src/app/jobs/updatePendingTransactions.ts b/apps/api/src/app/jobs/updatePendingTransactions.ts new file mode 100644 index 000000000..337cf18b8 --- /dev/null +++ b/apps/api/src/app/jobs/updatePendingTransactions.ts @@ -0,0 +1,56 @@ +import { TransactionState, TransactionType } from '@thxnetwork/common/enums'; +import { Transaction, TransactionDocument } from '@thxnetwork/api/models/Transaction'; +import { Wallet } from '../models/Wallet'; +import TransactionService from '@thxnetwork/api/services/TransactionService'; +import SafeService from '../services/SafeService'; +import { logger } from '../util/logger'; + +export async function updatePendingTransactions() { + const transactions: TransactionDocument[] = await Transaction.find({ + $or: [{ state: TransactionState.Confirmed }, { state: TransactionState.Sent }], + }).sort({ createdAt: 'asc' }); + + // Iterate over all tx sent to or proposed and confirmed by the relayer + for (const tx of transactions) { + switch (tx.state) { + // Legacy tx will not have this state + // Transactions is proposed and confirmed by the relayer, awaiting user wallet confirmation + case TransactionState.Confirmed: { + if (!tx.walletId) continue; + + const wallet = await Wallet.findById(tx.walletId); + + let pendingTx; + try { + pendingTx = await SafeService.getTransaction(wallet, tx.safeTxHash); + logger.debug(`Safe TX Found: ${tx.safeTxHash}`); + } catch (error) { + logger.error(error); + } + + // Safes for pools have a single signer (relayer) while safes for end users + // have 2 (relayer + web3auth mpc key) + const threshold = wallet.poolId ? 1 : 2; + if (pendingTx && pendingTx.confirmations.length >= threshold) { + logger.debug(`Safe TX Confirmed: ${tx.safeTxHash}`); + + try { + await SafeService.executeTransaction(wallet, tx.safeTxHash); + logger.debug(`Safe TX Executed: ${tx.safeTxHash}`); + } catch (error) { + await tx.updateOne({ state: TransactionState.Failed }); + logger.error(error); + } + } + break; + } + // TransactionType.Default is handled in tx service send methods + case TransactionState.Sent: { + if (tx.type == TransactionType.Relayed) { + TransactionService.queryTransactionStatusDefender(tx).catch((error) => logger.error(error)); + } + break; + } + } + } +} diff --git a/apps/api/src/app/middlewares/assertAccount.ts b/apps/api/src/app/middlewares/assertAccount.ts new file mode 100644 index 000000000..f63167119 --- /dev/null +++ b/apps/api/src/app/middlewares/assertAccount.ts @@ -0,0 +1,21 @@ +import { Response, Request, NextFunction } from 'express'; +import { NotFoundError } from '../util/errors'; +import { Pool } from '@thxnetwork/api/models'; +import AccountProxy from '../proxies/AccountProxy'; + +const assertAccount = async (req: Request, res: Response, next: NextFunction) => { + const account = await AccountProxy.findById(req.auth.sub); + if (!account) throw new Error('Account not found.'); + req.account = account; + + const poolId = req.header('X-PoolId'); + if (poolId) { + const pool = await Pool.findById(poolId); + if (!pool) throw new NotFoundError('Could not find campaign'); + req.campaign = pool; + } + + next(); +}; + +export { assertAccount }; diff --git a/apps/api/src/app/middlewares/assertPayment.ts b/apps/api/src/app/middlewares/assertPayment.ts new file mode 100644 index 000000000..8759977b1 --- /dev/null +++ b/apps/api/src/app/middlewares/assertPayment.ts @@ -0,0 +1,26 @@ +import { Response, Request, NextFunction } from 'express'; +import { BigNumber } from 'ethers'; +import { logger } from '../util/logger'; +import SafeService from '../services/SafeService'; +import PoolService from '../services/PoolService'; +import PaymentService from '../services/PaymentService'; + +/* + * This middleware function is used to assert payments of the pool owner. + * @dev Assumes that the poolId is available as 'id' param in the request + */ +export async function assertPayment(req: Request, res: Response, next: NextFunction) { + const pool = await PoolService.getById(req.params.id); + const safe = await SafeService.findOneByPool(pool); + const balanceInWei = await PaymentService.balanceOf(safe); + + // If pool.createdAt + 2 weeks is larger than now there should be a payment + const isPostTrial = Date.now() > new Date(pool.trialEndsAt).getTime(); + if (isPostTrial && BigNumber.from(balanceInWei).eq(0)) { + // @dev Disable until we agree on a better notification flow + // throw new ForbiddenError('Payment is required.'); + logger.info(JSON.stringify({ poolId: pool._id, safeAddress: safe.address, isPostTrial, balanceInWei })); + } + + next(); +} diff --git a/apps/api/src/app/middlewares/assertPoolAccess.ts b/apps/api/src/app/middlewares/assertPoolAccess.ts new file mode 100644 index 000000000..d4d3bb93d --- /dev/null +++ b/apps/api/src/app/middlewares/assertPoolAccess.ts @@ -0,0 +1,25 @@ +import { Response, Request, NextFunction } from 'express'; +import PoolService from '@thxnetwork/api/services/PoolService'; +import { AudienceUnauthorizedError, ForbiddenError, SubjectUnauthorizedError } from '@thxnetwork/api/util/errors'; +import { ALLOWED_API_CLIENT_ID } from '../config/secrets'; + +export async function assertPoolAccess( + req: Request & { user: { sub: string; aud: string } }, + res: Response, + next: NextFunction, +) { + if (req.auth.aud === ALLOWED_API_CLIENT_ID && ALLOWED_API_CLIENT_ID) return next(); + + const poolId = req.header('X-PoolId') || req.params.id; // Deprecate the header non pool child resources are tested + if (!poolId) throw new ForbiddenError('Missing id param or X-PoolId header'); + + // If there is a sub check if the user is an owner or collaborator + const isSubjectAllowed = await PoolService.isSubjectAllowed(req.auth.sub, poolId); + if (req.auth.sub !== req.auth.aud && !isSubjectAllowed) throw new SubjectUnauthorizedError(); + + const isAudienceAllowed = await PoolService.isAudienceAllowed(req.auth.aud, poolId); + // Equal sub and aud are client_credentials grants + if (req.auth.sub === req.auth.aud && !isAudienceAllowed) throw new AudienceUnauthorizedError(); + + next(); +} diff --git a/apps/api/src/app/middlewares/assertQuestAccess.ts b/apps/api/src/app/middlewares/assertQuestAccess.ts new file mode 100644 index 000000000..fb9dd7378 --- /dev/null +++ b/apps/api/src/app/middlewares/assertQuestAccess.ts @@ -0,0 +1,19 @@ +import { ForbiddenError } from '@thxnetwork/api/util/errors'; +import { QuestVariant } from '@thxnetwork/common/enums'; +import { Response, Request, NextFunction } from 'express'; +import { serviceMap } from '../services/interfaces/IQuestService'; + +// @dev For all social quests use QuestVariant.Twitter by default as we can not access the +// quest interaction type yet but have to assign the db collection +// in order to check quest access +export function assertQuestAccess(variant: QuestVariant) { + return async (req: Request, res: Response, next: NextFunction) => { + const poolId = req.header('X-PoolId'); + const Quest = serviceMap[variant].models.quest; + const quest = await Quest.findById(req.params.id); + if (poolId !== quest.poolId) { + throw new ForbiddenError(`Not your quest!`); + } + next(); + }; +} diff --git a/apps/api/src/app/middlewares/assertRequestInput.ts b/apps/api/src/app/middlewares/assertRequestInput.ts new file mode 100644 index 000000000..dcc0fe481 --- /dev/null +++ b/apps/api/src/app/middlewares/assertRequestInput.ts @@ -0,0 +1,13 @@ +import { Response, Request, NextFunction } from 'express'; +import { validationResult } from 'express-validator'; + +export function assertRequestInput(validations: any) { + return async (req: Request, res: Response, next: NextFunction) => { + await Promise.all(validations.map((validation: any) => validation.run(req))); + + const errors = validationResult(req); + if (errors.isEmpty()) return next(); + + res.status(400).json({ errors: errors.array() }); + }; +} diff --git a/apps/api/src/app/middlewares/assertUUID.ts b/apps/api/src/app/middlewares/assertUUID.ts new file mode 100644 index 000000000..9fa05c645 --- /dev/null +++ b/apps/api/src/app/middlewares/assertUUID.ts @@ -0,0 +1,11 @@ +import { Request } from 'express'; +import { ForbiddenError } from '../util/errors'; +import WalletService from '../services/WalletService'; + +const assertUUID = async (req: Request) => { + const sub = await WalletService.findOne({ uuid: req.body.uuid }); + if (!sub) throw new ForbiddenError('Sub not found for this uuid.'); + req.auth = { sub }; +}; + +export { assertUUID }; diff --git a/apps/api/src/app/middlewares/assertWallet.ts b/apps/api/src/app/middlewares/assertWallet.ts new file mode 100644 index 000000000..5585dbdbd --- /dev/null +++ b/apps/api/src/app/middlewares/assertWallet.ts @@ -0,0 +1,19 @@ +import { Response, Request, NextFunction } from 'express'; +import { ForbiddenError, NotFoundError } from '../util/errors'; +import WalletService from '../services/WalletService'; + +const assertWallet = async (req: Request, res: Response, next: NextFunction) => { + const walletId = req.query.walletId as string; + + if (walletId) { + const wallet = await WalletService.findById(walletId); + if (!wallet) throw new NotFoundError('Wallet not found'); + if (wallet.sub !== req.auth.sub) throw new ForbiddenError('Wallet not owned by sub.'); + + req.wallet = wallet; + } + + next(); +}; + +export { assertWallet }; diff --git a/apps/api/src/app/middlewares/checkJwt.ts b/apps/api/src/app/middlewares/checkJwt.ts new file mode 100644 index 000000000..7a12f3d04 --- /dev/null +++ b/apps/api/src/app/middlewares/checkJwt.ts @@ -0,0 +1,20 @@ +import jwksRsa from 'jwks-rsa'; +import expressJwtPermissions from 'express-jwt-permissions'; +import { expressjwt } from 'express-jwt'; +import { AUTH_URL } from '@thxnetwork/api/config/secrets'; + +export const checkJwt: any = expressjwt({ + secret: jwksRsa.expressJwtSecret({ + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 10, + jwksUri: `${AUTH_URL}/jwks`, + }), + issuer: AUTH_URL, + algorithms: ['RS256'], +}); + +export const guard: any = expressJwtPermissions({ + requestProperty: 'auth', + permissionsProperty: 'scope', +}); diff --git a/apps/api/src/app/middlewares/corsHandler.ts b/apps/api/src/app/middlewares/corsHandler.ts new file mode 100644 index 000000000..482968f9c --- /dev/null +++ b/apps/api/src/app/middlewares/corsHandler.ts @@ -0,0 +1,29 @@ +import cors from 'cors'; +import { AUTH_URL, API_URL, WALLET_URL, DASHBOARD_URL, WIDGET_URL, PUBLIC_URL } from '@thxnetwork/api/config/secrets'; +import ClientProxy from '@thxnetwork/api/proxies/ClientProxy'; + +export const corsHandler = cors(async (req: any, callback: any) => { + const origin = req.header('Origin'); + const allowedOrigins = [ + AUTH_URL, + API_URL, + WALLET_URL, + DASHBOARD_URL, + WIDGET_URL, + PUBLIC_URL, + 'https://app.thx.network', + 'https://dev-app.thx.network', + ]; + const isAllowedOrigin = await ClientProxy.isAllowedOrigin(origin); + + if (isAllowedOrigin) { + allowedOrigins.push(origin); + } + + if (!origin || allowedOrigins.includes(origin)) { + allowedOrigins.push(origin); + callback(null, { credentials: true, origin: allowedOrigins }); + } else { + callback(new Error(`${origin} is not allowed by CORS`)); + } +}); diff --git a/apps/api/src/app/middlewares/errorLogger.ts b/apps/api/src/app/middlewares/errorLogger.ts new file mode 100644 index 000000000..e51bb8bc9 --- /dev/null +++ b/apps/api/src/app/middlewares/errorLogger.ts @@ -0,0 +1,11 @@ +import { THXHttpError } from '@thxnetwork/api/util/errors'; +import { logger } from '@thxnetwork/api/util/logger'; +import { NextFunction, Request, Response } from 'express'; + +export const errorLogger = (error: Error, req: Request, res: Response, next: NextFunction) => { + if (!(error instanceof THXHttpError)) { + logger.error('Error caught:', error); + } + + next(error); +}; diff --git a/apps/api/src/app/middlewares/errorNormalizer.ts b/apps/api/src/app/middlewares/errorNormalizer.ts new file mode 100644 index 000000000..fd2308ecf --- /dev/null +++ b/apps/api/src/app/middlewares/errorNormalizer.ts @@ -0,0 +1,11 @@ +import { NextFunction, Request, Response } from 'express'; +import { UnauthorizedError } from '@thxnetwork/api/util/errors'; +import { UnauthorizedError as JWTUnauthorizedError } from 'express-jwt'; + +export const errorNormalizer = (error: Error, _req: Request, _res: Response, next: NextFunction) => { + if (error instanceof JWTUnauthorizedError) { + return next(new UnauthorizedError(error.message)); + } + + next(error); +}; diff --git a/apps/api/src/app/middlewares/errorOutput.ts b/apps/api/src/app/middlewares/errorOutput.ts new file mode 100644 index 000000000..c1dd96d80 --- /dev/null +++ b/apps/api/src/app/middlewares/errorOutput.ts @@ -0,0 +1,29 @@ +import { NextFunction, Request, Response } from 'express'; +import { THXHttpError } from '@thxnetwork/api/util/errors'; +import { NODE_ENV } from '@thxnetwork/api/config/secrets'; + +interface ErrorResponse { + error: { + message: string; + error?: Error; + rootMessage?: string; + stack?: string; + }; +} + +// Error handler needs to have 4 arguments. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const errorOutput = (error: any, req: Request, res: Response, next: NextFunction) => { + let status = 500; + const response: ErrorResponse = { error: { message: 'Unable to fulfill request' } }; + if (error instanceof THXHttpError || error.status) { + status = error.status; + response.error.message = error.message; + } else if (NODE_ENV !== 'production') { + response.error.error = error; + response.error.stack = error.stack; + } + + res.status(status); + res.json(response); +}; diff --git a/apps/api/src/app/middlewares/index.ts b/apps/api/src/app/middlewares/index.ts new file mode 100644 index 000000000..5d0f6b48d --- /dev/null +++ b/apps/api/src/app/middlewares/index.ts @@ -0,0 +1,12 @@ +export * from './assertPoolAccess'; +export * from './assertRequestInput'; +export * from './errorOutput'; +export * from './errorLogger'; +export * from './errorNormalizer'; +export * from './notFoundHandler'; +export * from './corsHandler'; +export * from './checkJwt'; +export * from './assertPayment'; +export * from './assertQuestAccess'; +export * from './assertAccount'; +export * from './assertWallet'; diff --git a/apps/api/src/app/middlewares/morgan.ts b/apps/api/src/app/middlewares/morgan.ts new file mode 100644 index 000000000..994de436b --- /dev/null +++ b/apps/api/src/app/middlewares/morgan.ts @@ -0,0 +1,16 @@ +import morgan from 'morgan'; +import { logger } from '../util/logger'; +import { NODE_ENV } from '../config/secrets'; + +const stream = { + write: (message) => logger.http(message), +}; + +const skip = () => { + return ['development', 'test'].includes(NODE_ENV || 'development'); +}; + +export default morgan( + ':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent" - :response-time ms', + { stream, skip }, +); diff --git a/apps/api/src/app/middlewares/notFoundHandler.ts b/apps/api/src/app/middlewares/notFoundHandler.ts new file mode 100644 index 000000000..95b5beb28 --- /dev/null +++ b/apps/api/src/app/middlewares/notFoundHandler.ts @@ -0,0 +1,6 @@ +import { NextFunction, Request, Response } from 'express'; +import { NotFoundError } from '@thxnetwork/api/util/errors'; + +export const notFoundHandler = (req: Request, res: Response, next: NextFunction) => { + next(new NotFoundError()); +}; diff --git a/apps/api/src/app/migrations/20240321144151-galachain-pkey.js b/apps/api/src/app/migrations/20240321144151-galachain-pkey.js new file mode 100644 index 000000000..ebae8457a --- /dev/null +++ b/apps/api/src/app/migrations/20240321144151-galachain-pkey.js @@ -0,0 +1,24 @@ +const { ethers } = require('ethers'); + +module.exports = { + async up(db, client) { + const poolsColl = db.collection('pool'); + const pools = await (await poolsColl.find({})).toArray(); + const operations = pools.map((pool) => { + const privateKey = ethers.Wallet.createRandom().privateKey; + return { + updateOne: { + filter: { _id: pool._id }, + update: { + $set: { 'settings.galachainPrivateKey': privateKey }, + }, + }, + }; + }); + await poolsColl.bulkWrite(operations); + }, + + async down(db, client) { + // + }, +}; diff --git a/apps/api/src/app/migrations/20240329123915-quest-metadata.js b/apps/api/src/app/migrations/20240329123915-quest-metadata.js new file mode 100644 index 000000000..8b9c03556 --- /dev/null +++ b/apps/api/src/app/migrations/20240329123915-quest-metadata.js @@ -0,0 +1,29 @@ +module.exports = { + async up(db, client) { + await db.collection('questdailyentry').updateMany( + {}, + { + $rename: { + ip: 'metadata.ip', + }, + }, + ); + await db.collection('questsocialentry').updateMany( + {}, + { + $rename: { + platformUserId: 'metadata.platformUserId', + publicMetrics: 'metadata.twitter', + }, + }, + ); + await db.collection('questweb3entry').updateMany({}, { $rename: { address: 'metadata.address' } }); + await db.collection('questgitcoinentry').updateMany({}, { $rename: { address: 'metadata.address' } }); + }, + + async down(db, client) { + // TODO write the statements to rollback your migration (if possible) + // Example: + // await db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: false}}); + }, +}; diff --git a/apps/api/src/app/migrations/20240430124026-message-query.js b/apps/api/src/app/migrations/20240430124026-message-query.js new file mode 100644 index 000000000..b81f2abad --- /dev/null +++ b/apps/api/src/app/migrations/20240430124026-message-query.js @@ -0,0 +1,84 @@ +const { ObjectId } = require('mongodb'); + +const createQuery = (operators) => { + const operatorKeys = Object.keys(operators); + if (!operatorKeys) return; + const media = !operators['media'] || ['ignore', null].includes(operators['media']) ? '' : ` ${operators['media']}`; + const query = operatorKeys + .map((key) => { + switch (key) { + case 'from': { + const items = operators[key]; + if (!items) return; + const authors = items.map((author) => `from:${author}`).join(' OR '); + return items.length > 1 ? `(${authors})` : authors; + } + case 'to': { + const items = operators[key]; + if (!items) return; + const authors = items.map((author) => `to:${author}`).join(' OR '); + return items.length > 1 ? `(${authors})` : authors; + } + case 'text': { + const items = operators[key]; + if (!items) return; + const texts = items.map((value) => `"${value}"`).join(' OR '); + return items.length > 1 ? `(${texts})` : texts; + } + case 'url': { + const items = operators[key]; + if (!items) return; + const urls = items.map((value) => `url:${value}`).join(' OR '); + return items.length > 1 ? `(${urls})` : urls; + } + case 'hashtags': { + const items = operators[key]; + if (!items) return; + const hashtags = items.map((tag) => `#${tag}`).join(' OR '); + return (items.length > 1 ? `(${hashtags})` : hashtags) + media; + } + case 'mentions': { + const items = operators[key]; + if (!items) return; + const mentions = items.map((tag) => `@${tag}`).join(' OR '); + return (items.length > 1 ? `(${mentions})` : mentions) + media; + } + } + return; + }) + .filter((query) => !!query) + .join(' '); + + return `${query} -is:retweet`; +}; + +module.exports = { + async up(db, client) { + const questColl = db.collection('questsocial'); + const quests = await questColl.find({ interaction: 6 }).toArray(); + + for (const quest of quests) { + if (!quest.contentMetadata) continue; + try { + const metadata = JSON.parse(quest.contentMetadata); + const operators = { + text: [quest.content], + }; + const query = createQuery(operators); + const contentMetadata = JSON.stringify({ + ...metadata, + query, + operators, + }); + + await questColl.updateOne({ _id: quest._id }, { $set: { content: query, contentMetadata } }); + } catch (error) { + console.log(error); + } + } + }, + + async down(db, client) { + // + }, +}; diff --git a/apps/api/src/app/models/Brand.ts b/apps/api/src/app/models/Brand.ts new file mode 100644 index 000000000..c8ebb880f --- /dev/null +++ b/apps/api/src/app/models/Brand.ts @@ -0,0 +1,15 @@ +import mongoose from 'mongoose'; + +export type TBrandUpdate = Partial; + +export const Brand = mongoose.model( + 'Brand', + new mongoose.Schema({ + poolId: String, + previewImgUrl: String, + logoImgUrl: String, + backgroundImgUrl: String, + widgetPreviewImgUrl: String, + }), + 'brand', +); diff --git a/apps/api/src/app/models/Client.ts b/apps/api/src/app/models/Client.ts new file mode 100644 index 000000000..dd98c556c --- /dev/null +++ b/apps/api/src/app/models/Client.ts @@ -0,0 +1,42 @@ +import mongoose from 'mongoose'; + +export type TClient = { + sub: string; + name: string; + poolId: string; + grantType: string; + clientId: string; + clientSecret: string; + requestUris: string[]; + registrationAccessToken: string; + origins?: string[]; +}; +export type TClientPayload = { + application_type: string; + grant_types: string[]; + scope: string; + response_types: string[]; + request_uris?: string[]; + redirect_uris?: string[]; + post_logout_redirect_uris?: string[]; +}; + +export type ClientDocument = mongoose.Document & TClient; + +export const Client = mongoose.model( + 'Client', + new mongoose.Schema( + { + sub: String, + name: String, + poolId: String, + clientId: String, + clientSecret: String, + grantType: String, + registrationAccessToken: String, + origins: [String], + }, + { timestamps: true }, + ), + 'client', +); diff --git a/apps/api/src/app/models/Collaborator.ts b/apps/api/src/app/models/Collaborator.ts new file mode 100644 index 000000000..160aed12a --- /dev/null +++ b/apps/api/src/app/models/Collaborator.ts @@ -0,0 +1,18 @@ +import mongoose from 'mongoose'; + +export type CollaboratorDocument = mongoose.Document & TCollaborator; + +export const Collaborator = mongoose.model( + 'Collaborator', + new mongoose.Schema( + { + sub: String, + poolId: String, + email: String, + uuid: String, + state: Number, + }, + { timestamps: true }, + ), + 'collaborator', +); diff --git a/apps/api/src/app/models/CouponCode.ts b/apps/api/src/app/models/CouponCode.ts new file mode 100644 index 000000000..885bc539a --- /dev/null +++ b/apps/api/src/app/models/CouponCode.ts @@ -0,0 +1,17 @@ +import mongoose from 'mongoose'; + +export type CouponCodeDocument = mongoose.Document & TCouponCode; + +export const CouponCode = mongoose.model( + 'CouponCode', + new mongoose.Schema( + { + poolId: String, + couponRewardId: String, + code: String, + sub: String, + }, + { timestamps: true }, + ), + 'couponcode', +); diff --git a/apps/api/src/app/models/DiscordGuild.ts b/apps/api/src/app/models/DiscordGuild.ts new file mode 100644 index 000000000..0d3a3b27f --- /dev/null +++ b/apps/api/src/app/models/DiscordGuild.ts @@ -0,0 +1,22 @@ +import mongoose from 'mongoose'; + +export type DiscordGuildDocument = mongoose.Document & TDiscordGuild; + +export const DiscordGuild = mongoose.model( + 'DiscordGuild', + new mongoose.Schema( + { + sub: String, + poolId: String, + guildId: String, + channelId: String, + adminRoleId: String, + name: String, + secret: String, + }, + { + timestamps: true, + }, + ), + 'discordguild', +); diff --git a/apps/api/src/app/models/DiscordMessage.ts b/apps/api/src/app/models/DiscordMessage.ts new file mode 100644 index 000000000..f6bef8dc4 --- /dev/null +++ b/apps/api/src/app/models/DiscordMessage.ts @@ -0,0 +1,18 @@ +import mongoose from 'mongoose'; + +export type DiscordMessageDocument = mongoose.Document & TDiscordMessage; + +export const DiscordMessage = mongoose.model( + 'DiscordMessage', + new mongoose.Schema( + { + guildId: String, + messageId: String, + memberId: String, + }, + { + timestamps: true, + }, + ), + 'discordmessage', +); diff --git a/apps/api/src/app/models/DiscordReaction.ts b/apps/api/src/app/models/DiscordReaction.ts new file mode 100644 index 000000000..fda975859 --- /dev/null +++ b/apps/api/src/app/models/DiscordReaction.ts @@ -0,0 +1,19 @@ +import mongoose from 'mongoose'; + +export type DiscordReactionDocument = mongoose.Document & TDiscordReaction; + +export const DiscordReaction = mongoose.model( + 'DiscordReaction', + new mongoose.Schema( + { + guildId: String, + messageId: String, + memberId: String, + content: String, + }, + { + timestamps: true, + }, + ), + 'discordreaction', +); diff --git a/apps/api/src/app/models/DiscordUser.ts b/apps/api/src/app/models/DiscordUser.ts new file mode 100644 index 000000000..7c24def95 --- /dev/null +++ b/apps/api/src/app/models/DiscordUser.ts @@ -0,0 +1,22 @@ +import mongoose from 'mongoose'; + +export type DiscordUserDocument = mongoose.Document & TDiscordUser; + +export const DiscordUser = mongoose.model( + 'DiscordUser', + new mongoose.Schema( + { + userId: String, + guildId: String, + profileImgUrl: String, + username: String, + publicMetrics: { + joinedAt: Date, + messageCount: Number, + reactionCount: Number, + }, + }, + { timestamps: true }, + ), + 'discorduser', +); diff --git a/apps/api/src/app/models/ERC1155.ts b/apps/api/src/app/models/ERC1155.ts new file mode 100644 index 000000000..dc38356ed --- /dev/null +++ b/apps/api/src/app/models/ERC1155.ts @@ -0,0 +1,30 @@ +import mongoose from 'mongoose'; +import { getAbiForContractName } from '@thxnetwork/api/services/ContractService'; +import { getProvider } from '@thxnetwork/api/util/network'; + +export type ERC1155Document = mongoose.Document & TERC1155; + +const schema = new mongoose.Schema( + { + variant: String, + chainId: Number, + sub: String, + name: String, + description: String, + transactions: [String], + address: String, + baseURL: String, + archived: Boolean, + logoImgUrl: String, + }, + { timestamps: true }, +); + +schema.virtual('contract').get(function () { + if (!this.address) return; + const { readProvider, defaultAccount } = getProvider(this.chainId); + const abi = getAbiForContractName('THX_ERC1155'); + return new readProvider.eth.Contract(abi, this.address, { from: defaultAccount }); +}); + +export const ERC1155 = mongoose.model('ERC1155', schema, 'erc1155'); diff --git a/apps/api/src/app/models/ERC1155Metadata.ts b/apps/api/src/app/models/ERC1155Metadata.ts new file mode 100644 index 000000000..989e1ca42 --- /dev/null +++ b/apps/api/src/app/models/ERC1155Metadata.ts @@ -0,0 +1,20 @@ +import mongoose from 'mongoose'; + +export type ERC1155MetadataDocument = mongoose.Document & TERC1155Metadata; + +export const ERC1155Metadata = mongoose.model( + 'ERC1155Metadata', + new mongoose.Schema( + { + erc1155Id: String, + imageUrl: String, + name: String, + image: String, + description: String, + externalUrl: String, + tokenId: Number, + }, + { timestamps: true }, + ), + 'erc1155metadata', +); diff --git a/apps/api/src/app/models/ERC1155Token.ts b/apps/api/src/app/models/ERC1155Token.ts new file mode 100644 index 000000000..fac51b990 --- /dev/null +++ b/apps/api/src/app/models/ERC1155Token.ts @@ -0,0 +1,22 @@ +import mongoose from 'mongoose'; + +export type ERC1155TokenDocument = mongoose.Document & TERC1155Token; + +export const ERC1155Token = mongoose.model( + 'ERC1155Token', + new mongoose.Schema( + { + sub: String, + state: Number, + erc1155Id: String, + metadataId: String, + tokenId: Number, + tokenUri: String, + recipient: String, + transactions: [String], + walletId: { type: String, index: 'hashed' }, + }, + { timestamps: true }, + ), + 'erc1155token', +); diff --git a/apps/api/src/app/models/ERC20.ts b/apps/api/src/app/models/ERC20.ts new file mode 100644 index 000000000..7c617909d --- /dev/null +++ b/apps/api/src/app/models/ERC20.ts @@ -0,0 +1,38 @@ +import mongoose from 'mongoose'; +import { getAbiForContractName } from '@thxnetwork/api/services/ContractService'; +import { ERC20Type } from '@thxnetwork/common/enums'; +import { getProvider } from '@thxnetwork/api/util/network'; + +export type ERC20Document = mongoose.Document & TERC20; + +const schema = new mongoose.Schema( + { + sub: String, + type: Number, + address: String, + chainId: Number, + name: String, + symbol: String, + transactions: [String], + archived: Boolean, + logoImgUrl: String, + }, + { timestamps: true }, +); + +schema.virtual('contractName').get(function () { + return getContractName(this.type); +}); + +schema.virtual('contract').get(function () { + if (!this.address) return; + const { readProvider, defaultAccount } = getProvider(this.chainId); + const abi = getAbiForContractName(getContractName(this.type)); + return new readProvider.eth.Contract(abi, this.address, { from: defaultAccount }); +}); + +function getContractName(type: ERC20Type) { + return type === ERC20Type.Unlimited ? 'UnlimitedSupplyToken' : 'LimitedSupplyToken'; +} + +export const ERC20 = mongoose.model('ERC20', schema, 'erc20'); diff --git a/apps/api/src/app/models/ERC20Token.ts b/apps/api/src/app/models/ERC20Token.ts new file mode 100644 index 000000000..a26153c2c --- /dev/null +++ b/apps/api/src/app/models/ERC20Token.ts @@ -0,0 +1,16 @@ +import mongoose from 'mongoose'; + +export type ERC20TokenDocument = mongoose.Document & TERC20Token; + +export const ERC20Token = mongoose.model( + 'ERC20Token', + new mongoose.Schema( + { + sub: String, + erc20Id: String, + walletId: { type: String, index: 'hashed' }, + }, + { timestamps: true }, + ), + 'erc20token', +); diff --git a/apps/api/src/app/models/ERC20Transfer.ts b/apps/api/src/app/models/ERC20Transfer.ts new file mode 100644 index 000000000..ab4a80301 --- /dev/null +++ b/apps/api/src/app/models/ERC20Transfer.ts @@ -0,0 +1,19 @@ +import mongoose from 'mongoose'; + +export type ERC20TransferDocument = mongoose.Document & TERC20Transfer; + +export const ERC20Transfer = mongoose.model( + 'ERC20Transfer', + new mongoose.Schema( + { + erc20Id: String, + from: String, + to: String, + chainId: Number, + transactionId: String, + sub: String, + }, + { timestamps: true }, + ), + 'erc20transfer', +); diff --git a/apps/api/src/app/models/ERC721.ts b/apps/api/src/app/models/ERC721.ts new file mode 100644 index 000000000..4dc3a362c --- /dev/null +++ b/apps/api/src/app/models/ERC721.ts @@ -0,0 +1,31 @@ +import mongoose from 'mongoose'; +import { getAbiForContractName } from '@thxnetwork/api/services/ContractService'; +import { getProvider } from '@thxnetwork/api/util/network'; + +export type ERC721Document = mongoose.Document & TERC721; + +const schema = new mongoose.Schema( + { + chainId: Number, + sub: String, + name: String, + symbol: String, + description: String, + transactions: [String], + address: String, + baseURL: String, + archived: Boolean, + logoImgUrl: String, + variant: String, + }, + { timestamps: true }, +); + +schema.virtual('contract').get(function () { + if (!this.address) return; + const { readProvider, defaultAccount } = getProvider(this.chainId); + const abi = getAbiForContractName('NonFungibleToken'); + return new readProvider.eth.Contract(abi, this.address, { from: defaultAccount }); +}); + +export const ERC721 = mongoose.model('ERC721', schema, 'erc721'); diff --git a/apps/api/src/app/models/ERC721Metadata.ts b/apps/api/src/app/models/ERC721Metadata.ts new file mode 100644 index 000000000..9eba455b4 --- /dev/null +++ b/apps/api/src/app/models/ERC721Metadata.ts @@ -0,0 +1,19 @@ +import mongoose from 'mongoose'; + +export type ERC721MetadataDocument = mongoose.Document & TERC721Metadata; + +export const ERC721Metadata = mongoose.model( + 'ERC721Metadata', + new mongoose.Schema( + { + erc721Id: String, + imageUrl: String, + name: String, + image: String, + description: String, + externalUrl: String, + }, + { timestamps: true }, + ), + 'erc721metadata', +); diff --git a/apps/api/src/app/models/ERC721Token.ts b/apps/api/src/app/models/ERC721Token.ts new file mode 100644 index 000000000..fdee045bb --- /dev/null +++ b/apps/api/src/app/models/ERC721Token.ts @@ -0,0 +1,23 @@ +import mongoose from 'mongoose'; + +export type ERC721TokenDocument = mongoose.Document & TERC721Token; + +export const ERC721Token = mongoose.model( + 'ERC721Token', + new mongoose.Schema( + { + erc721Id: String, + walletId: String, + metadataId: String, + tokenId: Number, + sub: String, + state: Number, + tokenUri: String, + recipient: String, + failReason: String, + transactions: [String], + }, + { timestamps: true }, + ), + 'erc721token', +); diff --git a/apps/api/src/app/models/ERC721Transfer.ts b/apps/api/src/app/models/ERC721Transfer.ts new file mode 100644 index 000000000..43a47e75c --- /dev/null +++ b/apps/api/src/app/models/ERC721Transfer.ts @@ -0,0 +1,20 @@ +import mongoose from 'mongoose'; + +export type ERC721TransferDocument = mongoose.Document & TERC721Transfer; + +export const ERC721Transfer = mongoose.model( + 'ERC721Transfer', + new mongoose.Schema( + { + erc721Id: String, + erc721TokenId: String, + from: String, + to: String, + chainId: Number, + transactionId: String, + sub: String, + }, + { timestamps: true }, + ), + 'erc721transfer', +); diff --git a/apps/api/src/app/models/Event.ts b/apps/api/src/app/models/Event.ts new file mode 100644 index 000000000..cb8fc33d7 --- /dev/null +++ b/apps/api/src/app/models/Event.ts @@ -0,0 +1,16 @@ +import mongoose from 'mongoose'; + +export type EventDocument = mongoose.Document & TEvent; + +export const Event = mongoose.model( + 'Event', + new mongoose.Schema( + { + identityId: String, + poolId: String, + name: String, + }, + { timestamps: true }, + ), + 'event', +); diff --git a/apps/api/src/app/models/Identity.ts b/apps/api/src/app/models/Identity.ts new file mode 100644 index 000000000..937bcb4eb --- /dev/null +++ b/apps/api/src/app/models/Identity.ts @@ -0,0 +1,16 @@ +import mongoose from 'mongoose'; + +export type IdentityDocument = mongoose.Document & TIdentity; + +export const Identity = mongoose.model( + 'Identity', + new mongoose.Schema( + { + poolId: String, + uuid: { unique: true, type: String }, + sub: String, + }, + { timestamps: true }, + ), + 'identity', +); diff --git a/apps/api/src/app/models/Invoice.ts b/apps/api/src/app/models/Invoice.ts new file mode 100644 index 000000000..6870dbfad --- /dev/null +++ b/apps/api/src/app/models/Invoice.ts @@ -0,0 +1,24 @@ +import mongoose from 'mongoose'; + +export type InvoiceDocument = mongoose.Document & TInvoice; + +export const Invoice = mongoose.model( + 'Invoice', + new mongoose.Schema( + { + poolId: String, + additionalUnitCount: Number, + costPerUnit: Number, + costSubscription: Number, + costTotal: Number, + currency: String, + plan: Number, + mapCount: Number, + mapLimit: Number, + periodStartDate: Date, + periodEndDate: Date, + }, + { timestamps: true }, + ), + 'invoice', +); diff --git a/apps/api/src/app/models/Job.ts b/apps/api/src/app/models/Job.ts new file mode 100644 index 000000000..a105ce448 --- /dev/null +++ b/apps/api/src/app/models/Job.ts @@ -0,0 +1,18 @@ +import mongoose from 'mongoose'; + +export type JobDocument = mongoose.Document & TJob; + +export const Job = mongoose.model( + 'Job', + new mongoose.Schema( + { + name: String, + data: Object, + lastRunAt: Date, + failedAt: Date, + failReason: String, + }, + { timestamps: true }, + ), + 'jobs', +); diff --git a/apps/api/src/app/models/Notification.ts b/apps/api/src/app/models/Notification.ts new file mode 100644 index 000000000..81e2c040e --- /dev/null +++ b/apps/api/src/app/models/Notification.ts @@ -0,0 +1,18 @@ +import mongoose from 'mongoose'; + +export type NotificationDocument = mongoose.Document & TNotification; + +export const Notification = mongoose.model( + 'Notifications', + new mongoose.Schema( + { + sub: String, + subjectId: String, + poolId: String, + subject: String, + message: String, + }, + { timestamps: true }, + ), + 'notification', +); diff --git a/apps/api/src/app/models/Participant.ts b/apps/api/src/app/models/Participant.ts new file mode 100644 index 000000000..e18c1c764 --- /dev/null +++ b/apps/api/src/app/models/Participant.ts @@ -0,0 +1,21 @@ +import mongoose from 'mongoose'; + +export type ParticipantDocument = mongoose.Document & TParticipant; + +export const Participant = mongoose.model( + 'Participant', + new mongoose.Schema( + { + sub: String, + poolId: String, + rank: Number, + score: Number, + balance: { type: Number, default: 0 }, + questEntryCount: Number, + riskAnalysis: { score: Number, reasons: [String] }, + isSubscribed: { type: Boolean, default: false }, + }, + { timestamps: true }, + ), + 'participant', +); diff --git a/apps/api/src/app/models/Payment.ts b/apps/api/src/app/models/Payment.ts new file mode 100644 index 000000000..b6d9188c6 --- /dev/null +++ b/apps/api/src/app/models/Payment.ts @@ -0,0 +1,15 @@ +import mongoose from 'mongoose'; + +export type PaymentDocument = mongoose.Document & TPayment; + +export const Payment = mongoose.model( + 'Payment', + new mongoose.Schema( + { + sub: String, + poolId: String, + }, + { timestamps: true }, + ), + 'payment', +); diff --git a/apps/api/src/app/models/Pool.ts b/apps/api/src/app/models/Pool.ts new file mode 100644 index 000000000..80e885ff7 --- /dev/null +++ b/apps/api/src/app/models/Pool.ts @@ -0,0 +1,52 @@ +import mongoose from 'mongoose'; +import { WIDGET_URL } from '../config/secrets'; + +export type PoolDocument = mongoose.Document & TPool; + +const schema = new mongoose.Schema( + { + sub: String, + chainId: Number, + transactions: [String], + version: String, + token: String, + signingSecret: String, + rank: Number, + safeAddress: String, + trialEndsAt: Date, + settings: { + title: String, + slug: String, + description: String, + startDate: Date, + endDate: Date, + isArchived: Boolean, + isPublished: { type: Boolean, default: false }, + isWeeklyDigestEnabled: { type: Boolean, default: true }, + isTwitterSyncEnabled: { type: Boolean, default: false }, + discordWebhookUrl: String, + galachainPrivateKey: String, + defaults: { + discordMessage: String, + conditionalRewards: { + title: String, + description: String, + amount: Number, + hashtag: String, + isPublished: { type: Boolean, default: false }, + locks: { type: [{ questId: String, variant: Number }], default: [] }, + }, + }, + authenticationMethods: [Number], + }, + }, + { timestamps: true }, +); + +schema.virtual('campaignURL').get(function (this: PoolDocument) { + const url = new URL(WIDGET_URL); + url.pathname = `/c/${this.settings.slug}`; + return url.toString(); +}); + +export const Pool = mongoose.model('Pool', schema, 'pool'); diff --git a/apps/api/src/app/models/QRCodeEntry.ts b/apps/api/src/app/models/QRCodeEntry.ts new file mode 100644 index 000000000..d02453829 --- /dev/null +++ b/apps/api/src/app/models/QRCodeEntry.ts @@ -0,0 +1,19 @@ +import mongoose from 'mongoose'; + +export type QRCodeEntryDocument = mongoose.Document & TQRCodeEntry; + +export const QRCodeEntry = mongoose.model( + 'QRCodeEntry', + new mongoose.Schema( + { + sub: String, + uuid: String, + redirectURL: String, + rewardId: String, + amount: Number, + claimedAt: Date, + }, + { timestamps: true }, + ), + 'qrcodeentry', +); diff --git a/apps/api/src/app/models/Quest.ts b/apps/api/src/app/models/Quest.ts new file mode 100644 index 000000000..9950b8481 --- /dev/null +++ b/apps/api/src/app/models/Quest.ts @@ -0,0 +1,13 @@ +export const questSchema = { + uuid: String, + poolId: String, + variant: Number, + title: String, + description: String, + image: String, + index: Number, + expiryDate: Date, + infoLinks: [{ label: String, url: String }], + isPublished: { type: Boolean, default: false }, + locks: { type: [{ questId: String, variant: Number }], default: [] }, +}; diff --git a/apps/api/src/app/models/QuestCustom.ts b/apps/api/src/app/models/QuestCustom.ts new file mode 100644 index 000000000..99b09ada8 --- /dev/null +++ b/apps/api/src/app/models/QuestCustom.ts @@ -0,0 +1,18 @@ +import mongoose from 'mongoose'; +import { questSchema } from '@thxnetwork/api/models/Quest'; + +export type QuestCustomDocument = mongoose.Document & TQuestCustom; + +export const QuestCustom = mongoose.model( + 'QuestCustom', + new mongoose.Schema( + { + ...(questSchema as any), + amount: Number, + limit: Number, + eventName: String, + }, + { timestamps: true }, + ), + 'questcustom', +); diff --git a/apps/api/src/app/models/QuestCustomEntry.ts b/apps/api/src/app/models/QuestCustomEntry.ts new file mode 100644 index 000000000..0c046bd34 --- /dev/null +++ b/apps/api/src/app/models/QuestCustomEntry.ts @@ -0,0 +1,19 @@ +import mongoose from 'mongoose'; + +export type QuestCustomEntryDocument = mongoose.Document & TQuestCustomEntry; + +export const QuestCustomEntry = mongoose.model( + 'QuestCustomEntry', + new mongoose.Schema( + { + questId: String, + sub: String, + uuid: String, + amount: Number, + isClaimed: Boolean, + poolId: String, + }, + { timestamps: true }, + ), + 'questcustomentry', +); diff --git a/apps/api/src/app/models/QuestDaily.ts b/apps/api/src/app/models/QuestDaily.ts new file mode 100644 index 000000000..adc438142 --- /dev/null +++ b/apps/api/src/app/models/QuestDaily.ts @@ -0,0 +1,17 @@ +import mongoose from 'mongoose'; +import { questSchema } from './Quest'; + +export type QuestDailyDocument = mongoose.Document & TQuestDaily; + +export const QuestDaily = mongoose.model( + 'QuestDaily', + new mongoose.Schema( + { + ...(questSchema as any), + amounts: [Number], + eventName: String, + }, + { timestamps: true }, + ), + 'questdaily', +); diff --git a/apps/api/src/app/models/QuestDailyEntry.ts b/apps/api/src/app/models/QuestDailyEntry.ts new file mode 100644 index 000000000..ef4a4c0f7 --- /dev/null +++ b/apps/api/src/app/models/QuestDailyEntry.ts @@ -0,0 +1,22 @@ +import mongoose from 'mongoose'; + +export type QuestDailyEntryDocument = mongoose.Document & TQuestDailyEntry; + +export const QuestDailyEntry = mongoose.model( + 'QuestDailyEntry', + new mongoose.Schema( + { + questId: String, + sub: String, + uuid: String, + amount: Number, + poolId: String, + metadata: { + state: Number, + ip: String, + }, + }, + { timestamps: true }, + ), + 'questdailyentry', +); diff --git a/apps/api/src/app/models/QuestGitcoin.ts b/apps/api/src/app/models/QuestGitcoin.ts new file mode 100644 index 000000000..7fbf75630 --- /dev/null +++ b/apps/api/src/app/models/QuestGitcoin.ts @@ -0,0 +1,18 @@ +import mongoose from 'mongoose'; +import { questSchema } from '@thxnetwork/api/models/Quest'; + +export type QuestGitcoinDocument = mongoose.Document & TQuestGitcoin; + +export const QuestGitcoin = mongoose.model( + 'QuestGitcoin', + new mongoose.Schema( + { + ...(questSchema as any), + amount: Number, + scorerId: Number, + score: Number, + }, + { timestamps: true }, + ), + 'questgitcoin', +); diff --git a/apps/api/src/app/models/QuestGitcoinEntry.ts b/apps/api/src/app/models/QuestGitcoinEntry.ts new file mode 100644 index 000000000..aad3fe0c4 --- /dev/null +++ b/apps/api/src/app/models/QuestGitcoinEntry.ts @@ -0,0 +1,21 @@ +import mongoose from 'mongoose'; + +export type QuestGitcoinEntryDocument = mongoose.Document & TQuestGitcoinEntry; + +export const QuestGitcoinEntry = mongoose.model( + 'QuestGitcoinEntry', + new mongoose.Schema( + { + poolId: String, + questId: String, + sub: String, + amount: Number, + metadata: { + address: String, + score: Number, + }, + }, + { timestamps: true }, + ), + 'questgitcoinentry', +); diff --git a/apps/api/src/app/models/QuestInvite.ts b/apps/api/src/app/models/QuestInvite.ts new file mode 100644 index 000000000..4e6cd17c3 --- /dev/null +++ b/apps/api/src/app/models/QuestInvite.ts @@ -0,0 +1,20 @@ +import mongoose from 'mongoose'; +import { questSchema } from '@thxnetwork/api/models/Quest'; + +export type QuestInviteDocument = mongoose.Document & TQuestInvite; + +export const QuestInvite = mongoose.model( + 'QuestInvite', + new mongoose.Schema( + { + ...(questSchema as any), + amount: Number, + pathname: String, + successUrl: String, + token: String, + isMandatoryReview: Boolean, + }, + { timestamps: true }, + ), + 'questinvite', +); diff --git a/apps/api/src/app/models/QuestInviteEntry.ts b/apps/api/src/app/models/QuestInviteEntry.ts new file mode 100644 index 000000000..363b99ca4 --- /dev/null +++ b/apps/api/src/app/models/QuestInviteEntry.ts @@ -0,0 +1,20 @@ +import mongoose from 'mongoose'; + +export type QuestInviteEntryDocument = mongoose.Document & TQuestInviteEntry; + +export const QuestInviteEntry = mongoose.model( + 'QuestInviteEntry', + new mongoose.Schema( + { + questId: String, + sub: String, + uuid: String, + amount: String, + isApproved: Boolean, + poolId: String, + metadata: String, + }, + { timestamps: true }, + ), + 'questinviteentry', +); diff --git a/apps/api/src/app/models/QuestSocial.ts b/apps/api/src/app/models/QuestSocial.ts new file mode 100644 index 000000000..f130cadbd --- /dev/null +++ b/apps/api/src/app/models/QuestSocial.ts @@ -0,0 +1,21 @@ +import mongoose from 'mongoose'; +import { questSchema } from './Quest'; + +export type QuestSocialDocument = mongoose.Document & TQuestSocial; + +export const QuestSocial = mongoose.model( + 'QuestSocial', + new mongoose.Schema( + { + ...(questSchema as any), + amount: Number, + platform: Number, + kind: String, + interaction: Number, + content: String, + contentMetadata: String, + }, + { timestamps: true }, + ), + 'questsocial', +); diff --git a/apps/api/src/app/models/QuestSocialEntry.ts b/apps/api/src/app/models/QuestSocialEntry.ts new file mode 100644 index 000000000..91776d4e5 --- /dev/null +++ b/apps/api/src/app/models/QuestSocialEntry.ts @@ -0,0 +1,34 @@ +import mongoose from 'mongoose'; + +export type QuestSocialEntryDocument = mongoose.Document & TQuestSocialEntry; + +export const QuestSocialEntry = mongoose.model( + 'QuestSocialEntry', + new mongoose.Schema( + { + questId: String, + sub: String, + amount: Number, + poolId: String, + metadata: { + platformUserId: String, + twitter: { + followersCount: Number, + followingCount: Number, + tweetCount: Number, + listedCount: Number, + likeCount: Number, + }, + discord: { + guildId: String, + username: String, + joinedAt: Date, + messageCount: Number, + reactionCount: Number, + }, + }, + }, + { timestamps: true }, + ), + 'questsocialentry', +); diff --git a/apps/api/src/app/models/QuestWeb3.ts b/apps/api/src/app/models/QuestWeb3.ts new file mode 100644 index 000000000..a74b95dd1 --- /dev/null +++ b/apps/api/src/app/models/QuestWeb3.ts @@ -0,0 +1,22 @@ +import mongoose from 'mongoose'; +import { questSchema } from '@thxnetwork/api/models/Quest'; + +export type QuestWeb3Document = mongoose.Document & TQuestWeb3; + +export const QuestWeb3 = mongoose.model( + 'QuestWeb3', + new mongoose.Schema( + { + ...(questSchema as any), + amount: Number, + contracts: Array({ + chainId: Number, + address: String, + }), + methodName: String, + threshold: String, + }, + { timestamps: true }, + ), + 'questweb3', +); diff --git a/apps/api/src/app/models/QuestWeb3Entry.ts b/apps/api/src/app/models/QuestWeb3Entry.ts new file mode 100644 index 000000000..ef5c44f87 --- /dev/null +++ b/apps/api/src/app/models/QuestWeb3Entry.ts @@ -0,0 +1,22 @@ +import mongoose from 'mongoose'; + +export type QuestWeb3EntryDocument = mongoose.Document & TQuestWeb3Entry; + +export const QuestWeb3Entry = mongoose.model( + 'QuestWeb3Entry', + new mongoose.Schema( + { + poolId: String, + questId: String, + sub: String, + amount: Number, + metadata: { + chainId: Number, + callResult: String, + address: String, + }, + }, + { timestamps: true }, + ), + 'questweb3entry', +); diff --git a/apps/api/src/app/models/QuestWebhook.ts b/apps/api/src/app/models/QuestWebhook.ts new file mode 100644 index 000000000..31ff80b15 --- /dev/null +++ b/apps/api/src/app/models/QuestWebhook.ts @@ -0,0 +1,18 @@ +import mongoose from 'mongoose'; +import { questSchema } from './Quest'; + +export type QuestWebhookDocument = mongoose.Document & TQuestWebhook; + +export const QuestWebhook = mongoose.model( + 'QuestWebhook', + new mongoose.Schema( + { + ...(questSchema as any), + amount: Number, + webhookId: String, + metadata: String, + }, + { timestamps: true }, + ), + 'questwebhook', +); diff --git a/apps/api/src/app/models/QuestWebhookEntry.ts b/apps/api/src/app/models/QuestWebhookEntry.ts new file mode 100644 index 000000000..7968c5c68 --- /dev/null +++ b/apps/api/src/app/models/QuestWebhookEntry.ts @@ -0,0 +1,19 @@ +import mongoose from 'mongoose'; + +export type QuestWebhookEntryDocument = mongoose.Document & TQuestWebhookEntry; + +export const QuestWebhookEntry = mongoose.model( + 'QuestWebhookEntry', + new mongoose.Schema( + { + questId: String, + poolId: String, + webhookId: String, + identityId: String, + sub: String, + amount: Number, + }, + { timestamps: true }, + ), + 'questwebhookentry', +); diff --git a/apps/api/src/app/models/Reward.ts b/apps/api/src/app/models/Reward.ts new file mode 100644 index 000000000..7301a1d31 --- /dev/null +++ b/apps/api/src/app/models/Reward.ts @@ -0,0 +1,22 @@ +export const rewardSchema = { + uuid: String, + poolId: String, + title: String, + description: String, + image: String, + pointPrice: Number, + expiryDate: Date, + limit: Number, + isPromoted: { type: Boolean, default: false }, + isPublished: { type: Boolean, default: false }, + locks: { type: [{ questId: String, variant: Number }], default: [] }, + claimAmount: Number, + claimLimit: Number, +}; + +export const rewardPaymentSchema = { + poolId: String, + rewardId: String, + sub: String, + amount: Number, +}; diff --git a/apps/api/src/app/models/RewardCoin.ts b/apps/api/src/app/models/RewardCoin.ts new file mode 100644 index 000000000..a54d6108d --- /dev/null +++ b/apps/api/src/app/models/RewardCoin.ts @@ -0,0 +1,19 @@ +import mongoose from 'mongoose'; +import { RewardVariant } from '@thxnetwork/common/enums'; +import { rewardSchema } from './Reward'; + +export type RewardCoinDocument = mongoose.Document & TRewardCoin; + +export const RewardCoin = mongoose.model( + 'RewardCoin', + new mongoose.Schema( + { + ...rewardSchema, + variant: { type: Number, default: RewardVariant.Coin }, + erc20Id: String, + amount: String, + }, + { timestamps: true }, + ), + 'rewardcoin', +); diff --git a/apps/api/src/app/models/RewardCoinPayment.ts b/apps/api/src/app/models/RewardCoinPayment.ts new file mode 100644 index 000000000..5d9500190 --- /dev/null +++ b/apps/api/src/app/models/RewardCoinPayment.ts @@ -0,0 +1,17 @@ +import mongoose from 'mongoose'; + +export type RewardCoinPaymentDocument = mongoose.Document & TRewardCoinPayment; + +export const RewardCoinPayment = mongoose.model( + 'RewardCoinPayment', + new mongoose.Schema( + { + rewardId: String, + sub: String, + poolId: String, + amount: Number, + }, + { timestamps: true }, + ), + 'rewardcoinpayment', +); diff --git a/apps/api/src/app/models/RewardCoupon.ts b/apps/api/src/app/models/RewardCoupon.ts new file mode 100644 index 000000000..badc6d39a --- /dev/null +++ b/apps/api/src/app/models/RewardCoupon.ts @@ -0,0 +1,18 @@ +import mongoose from 'mongoose'; +import { rewardSchema } from './Reward'; +import { RewardVariant } from '@thxnetwork/common/enums'; + +export type RewardCouponDocument = mongoose.Document & TRewardCoupon; + +export const RewardCoupon = mongoose.model( + 'RewardCoupon', + new mongoose.Schema( + { + ...rewardSchema, + variant: { type: Number, default: RewardVariant.Coupon }, + webshopURL: String, + }, + { timestamps: true }, + ), + 'rewardcoupon', +); diff --git a/apps/api/src/app/models/RewardCouponPayment.ts b/apps/api/src/app/models/RewardCouponPayment.ts new file mode 100644 index 000000000..025e9e52e --- /dev/null +++ b/apps/api/src/app/models/RewardCouponPayment.ts @@ -0,0 +1,16 @@ +import mongoose from 'mongoose'; +import { rewardPaymentSchema } from './Reward'; + +export type RewardCouponPaymentDocument = mongoose.Document & TRewardCouponPayment; + +export const RewardCouponPayment = mongoose.model( + 'RewardCouponPayment', + new mongoose.Schema( + { + ...rewardPaymentSchema, + couponCodeId: String, + }, + { timestamps: true }, + ), + 'rewardcouponpayment', +); diff --git a/apps/api/src/app/models/RewardCustom.ts b/apps/api/src/app/models/RewardCustom.ts new file mode 100644 index 000000000..f7b8e42a9 --- /dev/null +++ b/apps/api/src/app/models/RewardCustom.ts @@ -0,0 +1,19 @@ +import mongoose from 'mongoose'; +import { rewardSchema } from './Reward'; +import { RewardVariant } from '@thxnetwork/common/enums'; + +export type RewardCustomDocument = mongoose.Document & TRewardCustom; + +export const RewardCustom = mongoose.model( + 'RewardCustom', + new mongoose.Schema( + { + ...rewardSchema, + variant: { type: Number, default: RewardVariant.Custom }, + metadata: String, + webhookId: String, + }, + { timestamps: true }, + ), + 'rewardcustom', +); diff --git a/apps/api/src/app/models/RewardCustomPayment.ts b/apps/api/src/app/models/RewardCustomPayment.ts new file mode 100644 index 000000000..459b79a16 --- /dev/null +++ b/apps/api/src/app/models/RewardCustomPayment.ts @@ -0,0 +1,15 @@ +import mongoose from 'mongoose'; +import { rewardPaymentSchema } from './Reward'; + +export type RewardCustomPaymentDocument = mongoose.Document & TRewardCustomPayment; + +export const RewardCustomPayment = mongoose.model( + 'RewardCustomPayment', + new mongoose.Schema( + { + ...rewardPaymentSchema, + }, + { timestamps: true }, + ), + 'rewardcustompayment', +); diff --git a/apps/api/src/app/models/RewardDiscordRole.ts b/apps/api/src/app/models/RewardDiscordRole.ts new file mode 100644 index 000000000..f38ecd684 --- /dev/null +++ b/apps/api/src/app/models/RewardDiscordRole.ts @@ -0,0 +1,18 @@ +import mongoose from 'mongoose'; +import { rewardSchema } from './Reward'; +import { RewardVariant } from '@thxnetwork/common/enums'; + +export type RewardDiscordRoleDocument = mongoose.Document & TRewardDiscordRole; + +export const RewardDiscordRole = mongoose.model( + 'RewardDiscordRole', + new mongoose.Schema( + { + ...rewardSchema, + variant: { type: Number, default: RewardVariant.DiscordRole }, + discordRoleId: String, + }, + { timestamps: true }, + ), + 'rewarddiscordrole', +); diff --git a/apps/api/src/app/models/RewardDiscordRolePayment.ts b/apps/api/src/app/models/RewardDiscordRolePayment.ts new file mode 100644 index 000000000..fccdf8ee8 --- /dev/null +++ b/apps/api/src/app/models/RewardDiscordRolePayment.ts @@ -0,0 +1,16 @@ +import mongoose from 'mongoose'; +import { rewardPaymentSchema } from './Reward'; + +export type RewardDiscordRolePaymentDocument = mongoose.Document & TRewardDiscordRolePayment; + +export const RewardDiscordRolePayment = mongoose.model( + 'RewardDiscordRolePayment', + new mongoose.Schema( + { + ...rewardPaymentSchema, + discordRoleId: String, + }, + { timestamps: true }, + ), + 'rewarddiscordrolepayment', +); diff --git a/apps/api/src/app/models/RewardGalachain.ts b/apps/api/src/app/models/RewardGalachain.ts new file mode 100644 index 000000000..75aec01c8 --- /dev/null +++ b/apps/api/src/app/models/RewardGalachain.ts @@ -0,0 +1,26 @@ +import mongoose from 'mongoose'; +import { rewardSchema } from './Reward'; +import { RewardVariant } from '@thxnetwork/common/enums'; + +export type RewardGalachainDocument = mongoose.Document & TRewardGalachain; + +export const RewardGalachain = mongoose.model( + 'RewardGalachain', + new mongoose.Schema( + { + ...rewardSchema, + variant: { type: Number, default: RewardVariant.Galachain }, + amount: String, + contractChannelName: { type: String, required: true }, + contractChaincodeName: { type: String, required: true }, + contractContractName: { type: String, required: true }, + tokenCollection: { type: String, required: true }, + tokenCategory: { type: String, required: true }, + tokenType: { type: String, required: true }, + tokenAdditionalKey: { type: String, required: true }, + tokenInstance: { type: Number, required: true }, + }, + { timestamps: true }, + ), + 'rewardgalachain', +); diff --git a/apps/api/src/app/models/RewardGalachainPayment.ts b/apps/api/src/app/models/RewardGalachainPayment.ts new file mode 100644 index 000000000..de4bf79e6 --- /dev/null +++ b/apps/api/src/app/models/RewardGalachainPayment.ts @@ -0,0 +1,16 @@ +import mongoose from 'mongoose'; +import { rewardPaymentSchema } from './Reward'; + +export type RewardGalachainPaymentDocument = mongoose.Document & TRewardGalachainPayment; + +export const RewardGalachainPayment = mongoose.model( + 'RewardGalachainPayment', + new mongoose.Schema( + { + ...rewardPaymentSchema, + amount: String, + }, + { timestamps: true }, + ), + 'rewardgalachainpayment', +); diff --git a/apps/api/src/app/models/RewardNFT.ts b/apps/api/src/app/models/RewardNFT.ts new file mode 100644 index 000000000..2ad9763ef --- /dev/null +++ b/apps/api/src/app/models/RewardNFT.ts @@ -0,0 +1,25 @@ +import mongoose from 'mongoose'; +import { RewardVariant } from '@thxnetwork/common/enums'; +import { rewardSchema } from './Reward'; + +export type RewardNFTDocument = mongoose.Document & TRewardNFT; + +export const RewardNFT = mongoose.model( + 'RewardNFT', + new mongoose.Schema( + { + ...rewardSchema, + variant: { type: Number, default: RewardVariant.NFT }, + erc721Id: String, + erc1155Id: String, + erc1155Amount: String, + metadataId: String, + tokenId: String, + price: Number, + priceCurrency: String, + redirectUrl: String, + }, + { timestamps: true }, + ), + 'rewardnft', +); diff --git a/apps/api/src/app/models/RewardNFTPayment.ts b/apps/api/src/app/models/RewardNFTPayment.ts new file mode 100644 index 000000000..d7d49b5fb --- /dev/null +++ b/apps/api/src/app/models/RewardNFTPayment.ts @@ -0,0 +1,15 @@ +import mongoose from 'mongoose'; +import { rewardPaymentSchema } from './Reward'; + +export type RewardNFTPaymentDocument = mongoose.Document & TRewardNFTPayment; + +export const RewardNFTPayment = mongoose.model( + 'RewardNFTPayment', + new mongoose.Schema( + { + ...rewardPaymentSchema, + }, + { timestamps: true }, + ), + 'rewardnftpayment', +); diff --git a/apps/api/src/app/models/Transaction.ts b/apps/api/src/app/models/Transaction.ts new file mode 100644 index 000000000..53ccef3ad --- /dev/null +++ b/apps/api/src/app/models/Transaction.ts @@ -0,0 +1,30 @@ +import mongoose from 'mongoose'; + +export type TransactionDocument = mongoose.Document & TTransaction; + +export const Transaction = mongoose.model( + 'Transaction', + new mongoose.Schema( + { + from: String, + to: String, + nonce: Number, + walletId: String, + transactionId: String, + transactionHash: String, + safeTxHash: String, + gas: String, + baseFee: String, + maxFeePerGas: String, + maxPriorityFeePerGas: String, + type: Number, + state: { type: Number, index: { sparse: true } }, + call: { fn: String, args: String }, + chainId: Number, + failReason: String, + callback: {}, + }, + { timestamps: true }, + ), + 'transaction', +); diff --git a/apps/api/src/app/models/TwitterFollower.ts b/apps/api/src/app/models/TwitterFollower.ts new file mode 100644 index 000000000..21a1e5b10 --- /dev/null +++ b/apps/api/src/app/models/TwitterFollower.ts @@ -0,0 +1,17 @@ +import mongoose from 'mongoose'; + +export type TwitterFollowerDocument = mongoose.Document & TTwitterFollower; + +const twitterFollowerSchema = new mongoose.Schema( + { + userId: String, + targetUserId: String, + }, + { timestamps: true }, +); + +export const TwitterFollower = mongoose.model( + 'TwitterFollower', + twitterFollowerSchema, + 'twitterfollower', +); diff --git a/apps/api/src/app/models/TwitterLike.ts b/apps/api/src/app/models/TwitterLike.ts new file mode 100644 index 000000000..f759fef3d --- /dev/null +++ b/apps/api/src/app/models/TwitterLike.ts @@ -0,0 +1,15 @@ +import mongoose from 'mongoose'; + +export type TwitterLikeDocument = mongoose.Document & TTwitterLike; + +export const TwitterLike = mongoose.model( + 'TwitterLike', + new mongoose.Schema( + { + userId: String, + postId: String, + }, + { timestamps: true }, + ), + 'twitterlike', +); diff --git a/apps/api/src/app/models/TwitterPost.ts b/apps/api/src/app/models/TwitterPost.ts new file mode 100644 index 000000000..6f5c09e95 --- /dev/null +++ b/apps/api/src/app/models/TwitterPost.ts @@ -0,0 +1,25 @@ +import mongoose from 'mongoose'; + +export type TwitterPostDocument = mongoose.Document & TTwitterPost; + +export const TwitterPost = mongoose.model( + 'TwitterPost', + new mongoose.Schema( + { + userId: String, + postId: String, + queryId: String, + text: String, + publicMetrics: { + retweetCount: Number, + replyCount: Number, + likeCount: Number, + quoteCount: Number, + bookmarkCount: Number, + impressionCount: Number, + }, + }, + { timestamps: true }, + ), + 'twitterpost', +); diff --git a/apps/api/src/app/models/TwitterQuery.ts b/apps/api/src/app/models/TwitterQuery.ts new file mode 100644 index 000000000..43ce2f1e9 --- /dev/null +++ b/apps/api/src/app/models/TwitterQuery.ts @@ -0,0 +1,33 @@ +import mongoose from 'mongoose'; + +export type TwitterQueryDocument = mongoose.Document & TTwitterQuery; + +export const TwitterQuery = mongoose.model( + 'TwitterQuery', + new mongoose.Schema( + { + poolId: String, + query: String, + operators: { + from: [String], + to: [String], + text: [String], + url: [String], + hashtags: [String], + mentions: [String], + media: String, + excludes: [String], + }, + defaults: { + title: String, + description: String, + amount: Number, + isPublished: Boolean, + expiryInDays: Number, + locks: [{ questId: String, variant: Number }], + }, + }, + { timestamps: true }, + ), + 'twitterquery', +); diff --git a/apps/api/src/app/models/TwitterRepost.ts b/apps/api/src/app/models/TwitterRepost.ts new file mode 100644 index 000000000..8d3b307c7 --- /dev/null +++ b/apps/api/src/app/models/TwitterRepost.ts @@ -0,0 +1,15 @@ +import mongoose from 'mongoose'; + +export type TwitterRepostDocument = mongoose.Document & TTwitterRepost; + +export const TwitterRepost = mongoose.model( + 'TwitterRepost', + new mongoose.Schema( + { + userId: String, + postId: String, + }, + { timestamps: true }, + ), + 'twitterrepost', +); diff --git a/apps/api/src/app/models/TwitterUser.ts b/apps/api/src/app/models/TwitterUser.ts new file mode 100644 index 000000000..c012f7b6b --- /dev/null +++ b/apps/api/src/app/models/TwitterUser.ts @@ -0,0 +1,24 @@ +import mongoose from 'mongoose'; + +export type TwitterUserDocument = mongoose.Document & TTwitterUser; + +export const TwitterUser = mongoose.model( + 'TwitterUser', + new mongoose.Schema( + { + userId: String, + profileImgUrl: String, + name: String, + username: String, + publicMetrics: { + followersCount: Number, + followingCount: Number, + tweetCount: Number, + listedCount: Number, + likeCount: Number, + }, + }, + { timestamps: true }, + ), + 'twitteruser', +); diff --git a/apps/api/src/app/models/Wallet.ts b/apps/api/src/app/models/Wallet.ts new file mode 100644 index 000000000..6a0eb30e8 --- /dev/null +++ b/apps/api/src/app/models/Wallet.ts @@ -0,0 +1,22 @@ +import mongoose from 'mongoose'; + +export type WalletDocument = mongoose.Document & TWallet; + +export const Wallet = mongoose.model( + 'Wallet', + new mongoose.Schema( + { + uuid: String, + expiresAt: Date, + poolId: String, + address: String, + sub: { type: String, index: 'hashed' }, + chainId: Number, + version: String, + safeVersion: String, + variant: String, + }, + { timestamps: true }, + ), + 'wallet', +); diff --git a/apps/api/src/app/models/Webhook.ts b/apps/api/src/app/models/Webhook.ts new file mode 100644 index 000000000..43841317d --- /dev/null +++ b/apps/api/src/app/models/Webhook.ts @@ -0,0 +1,17 @@ +import mongoose from 'mongoose'; + +export type WebhookDocument = mongoose.Document & TWebhook; + +export const Webhook = mongoose.model( + 'Webhook', + new mongoose.Schema( + { + sub: String, + poolId: String, + url: String, + active: { default: false, type: Boolean }, + }, + { timestamps: true }, + ), + 'webhook', +); diff --git a/apps/api/src/app/models/WebhookRequest.ts b/apps/api/src/app/models/WebhookRequest.ts new file mode 100644 index 000000000..0d2d06228 --- /dev/null +++ b/apps/api/src/app/models/WebhookRequest.ts @@ -0,0 +1,19 @@ +import mongoose from 'mongoose'; + +export type WebhookRequestDocument = mongoose.Document & TWebhookRequest; + +export const WebhookRequest = mongoose.model( + 'WebhookRequest', + new mongoose.Schema( + { + webhookId: String, + payload: String, + attempts: { type: Number, default: 0 }, + state: Number, + httpStatus: Number, + failReason: String, + }, + { timestamps: true }, + ), + 'webhookrequest', +); diff --git a/apps/api/src/app/models/Widget.ts b/apps/api/src/app/models/Widget.ts new file mode 100644 index 000000000..49f6bd0ab --- /dev/null +++ b/apps/api/src/app/models/Widget.ts @@ -0,0 +1,23 @@ +import mongoose from 'mongoose'; + +export type WidgetDocument = mongoose.Document & TWidget; + +export const Widget = mongoose.model( + 'Widget', + new mongoose.Schema( + { + uuid: String, + poolId: String, + iconImg: String, + align: String, + message: String, + domain: String, + theme: String, + cssSelector: String, + active: { default: false, type: Boolean }, + isPublished: { type: Boolean, default: true }, + }, + { timestamps: true }, + ), + 'widget', +); diff --git a/apps/api/src/app/models/index.ts b/apps/api/src/app/models/index.ts new file mode 100644 index 000000000..004dd492d --- /dev/null +++ b/apps/api/src/app/models/index.ts @@ -0,0 +1,61 @@ +export { Brand } from './Brand'; +export { QRCodeEntry, QRCodeEntryDocument } from './QRCodeEntry'; +export { Client, ClientDocument } from './Client'; +export { Collaborator, CollaboratorDocument } from './Collaborator'; +export { CouponCode, CouponCodeDocument } from './CouponCode'; +export { DiscordGuild, DiscordGuildDocument } from './DiscordGuild'; +export { DiscordMessage, DiscordMessageDocument } from './DiscordMessage'; +export { DiscordReaction, DiscordReactionDocument } from './DiscordReaction'; +export { ERC1155, ERC1155Document } from './ERC1155'; +export { ERC1155Metadata, ERC1155MetadataDocument } from './ERC1155Metadata'; +export { ERC1155Token, ERC1155TokenDocument } from './ERC1155Token'; +export { ERC20, ERC20Document } from './ERC20'; +export { ERC20Token, ERC20TokenDocument } from './ERC20Token'; +export { ERC20Transfer, ERC20TransferDocument } from './ERC20Transfer'; +export { ERC721, ERC721Document } from './ERC721'; +export { ERC721Metadata, ERC721MetadataDocument } from './ERC721Metadata'; +export { ERC721Token, ERC721TokenDocument } from './ERC721Token'; +export { ERC721Transfer, ERC721TransferDocument } from './ERC721Transfer'; +export { Event, EventDocument } from './Event'; +export { Identity, IdentityDocument } from './Identity'; +export { Invoice, InvoiceDocument } from './Invoice'; +export { Job, JobDocument } from './Job'; +export { Notification, NotificationDocument } from './Notification'; +export { Participant, ParticipantDocument } from './Participant'; +export { Pool, PoolDocument } from './Pool'; +export { QuestCustom, QuestCustomDocument } from './QuestCustom'; +export { QuestCustomEntry, QuestCustomEntryDocument } from './QuestCustomEntry'; +export { QuestDaily, QuestDailyDocument } from './QuestDaily'; +export { QuestDailyEntry, QuestDailyEntryDocument } from './QuestDailyEntry'; +export { QuestWebhook, QuestWebhookDocument } from './QuestWebhook'; +export { QuestWebhookEntry, QuestWebhookEntryDocument } from './QuestWebhookEntry'; +export { QuestGitcoin, QuestGitcoinDocument } from './QuestGitcoin'; +export { QuestGitcoinEntry, QuestGitcoinEntryDocument } from './QuestGitcoinEntry'; +export { QuestInvite, QuestInviteDocument } from './QuestInvite'; +export { QuestInviteEntry, QuestInviteEntryDocument } from './QuestInviteEntry'; +export { QuestSocial, QuestSocialDocument } from './QuestSocial'; +export { QuestSocialEntry, QuestSocialEntryDocument } from './QuestSocialEntry'; +export { QuestWeb3, QuestWeb3Document } from './QuestWeb3'; +export { QuestWeb3Entry, QuestWeb3EntryDocument } from './QuestWeb3Entry'; +export { RewardCoin, RewardCoinDocument } from './RewardCoin'; +export { RewardCoinPayment, RewardCoinPaymentDocument } from './RewardCoinPayment'; +export { RewardCoupon, RewardCouponDocument } from './RewardCoupon'; +export { RewardCouponPayment, RewardCouponPaymentDocument } from './RewardCouponPayment'; +export { RewardCustom, RewardCustomDocument } from './RewardCustom'; +export { RewardCustomPayment, RewardCustomPaymentDocument } from './RewardCustomPayment'; +export { RewardDiscordRole, RewardDiscordRoleDocument } from './RewardDiscordRole'; +export { RewardDiscordRolePayment, RewardDiscordRolePaymentDocument } from './RewardDiscordRolePayment'; +export { RewardNFT, RewardNFTDocument } from './RewardNFT'; +export { RewardNFTPayment, RewardNFTPaymentDocument } from './RewardNFTPayment'; +export { RewardGalachain, RewardGalachainDocument } from './RewardGalachain'; +export { RewardGalachainPayment, RewardGalachainPaymentDocument } from './RewardGalachainPayment'; +export { Transaction, TransactionDocument } from './Transaction'; +export { TwitterFollower, TwitterFollowerDocument } from './TwitterFollower'; +export { TwitterLike, TwitterLikeDocument } from './TwitterLike'; +export { TwitterRepost, TwitterRepostDocument } from './TwitterRepost'; +export { TwitterUser, TwitterUserDocument } from './TwitterUser'; +export { TwitterQuery, TwitterQueryDocument } from './TwitterQuery'; +export { Wallet, WalletDocument } from './Wallet'; +export { Webhook, WebhookDocument } from './Webhook'; +export { WebhookRequest, WebhookRequestDocument } from './WebhookRequest'; +export { Widget, WidgetDocument } from './Widget'; diff --git a/apps/api/src/app/proxies/AccountProxy.ts b/apps/api/src/app/proxies/AccountProxy.ts new file mode 100644 index 000000000..097c2f33b --- /dev/null +++ b/apps/api/src/app/proxies/AccountProxy.ts @@ -0,0 +1,113 @@ +import { authClient, getAuthAccessToken } from '@thxnetwork/api/util/auth'; +import { BadRequestError } from '../util/errors'; +import { AccessTokenKind, OAuthScope } from '@thxnetwork/common/enums'; +import { AxiosRequestConfig } from 'axios'; + +export default class AccountProxy { + static async request(config: AxiosRequestConfig) { + const { status, data } = await authClient({ + ...config, + headers: { + Authorization: await getAuthAccessToken(), + }, + }); + + if (status >= 400 && status <= 500 && data.error) { + throw new BadRequestError(data.error.message); + } + + return data; + } + + static async getToken(account: TAccount, kind: AccessTokenKind, requiredScopes: OAuthScope[] = []) { + const token = await this.request({ + method: 'GET', + url: `/accounts/${account.sub}/tokens/${kind}`, + }); + if (token && requiredScopes.every((scope) => token.scopes.includes(scope))) return token; + } + + static disconnect(account: TAccount, kind: AccessTokenKind) { + return this.request({ + method: 'DELETE', + url: `/accounts/${account.sub}/tokens/${kind}`, + }); + } + + static findById(sub: string): Promise { + return this.request({ + method: 'GET', + url: `/accounts/${sub}`, + }); + } + + static update(sub: string, updates: Partial): Promise { + return this.request({ + method: 'PATCH', + url: `/accounts/${sub}`, + data: updates, + }); + } + + static remove(sub: string) { + return this.request({ + method: 'DELETE', + url: `/accounts/${sub}`, + }); + } + + static find({ subs, query }: Partial<{ subs: string[]; query: string }>): Promise { + return this.request({ + method: 'POST', + url: '/accounts', + data: { subs: JSON.stringify(subs), query }, + }); + } + + static getByDiscordId(discordId: string): Promise { + return this.request({ + method: 'GET', + url: `/accounts/discord/${discordId}`, + }); + } + + static getByEmail(email: string): Promise { + return this.request({ + method: 'GET', + url: `/accounts/email/${email}`, + }); + } + + static getByAddress(address: string): Promise { + return this.request({ + method: 'GET', + url: `/accounts/address/${address}`, + }); + } + + static getByIdentity(identity: string): Promise { + return this.request({ + method: 'GET', + url: `/accounts/identity/${identity}`, + }); + } + + static async isEmailDuplicate(email: string) { + try { + await authClient({ + method: 'GET', + url: `/accounts/email/${email}`, + headers: { + Authorization: await getAuthAccessToken(), + }, + }); + + return true; + } catch (error) { + if (error.response.status === 404) { + return false; + } + throw error; + } + } +} diff --git a/apps/api/src/app/proxies/ClientProxy.ts b/apps/api/src/app/proxies/ClientProxy.ts new file mode 100644 index 000000000..812a5d3f0 --- /dev/null +++ b/apps/api/src/app/proxies/ClientProxy.ts @@ -0,0 +1,87 @@ +import { INITIAL_ACCESS_TOKEN } from '@thxnetwork/api/config/secrets'; +import { Client, ClientDocument, TClient, TClientPayload } from '@thxnetwork/api/models/Client'; +import { authClient } from '@thxnetwork/api/util/auth'; +import { paginatedResults } from '@thxnetwork/api/util/pagination'; + +export default class ClientProxy { + static async getCredentials(client: ClientDocument) { + const { data } = await authClient({ + method: 'GET', + url: `/reg/${client.clientId}?access_token=${client.registrationAccessToken}`, + }); + + client.clientSecret = data['client_secret']; + client.requestUris = data['request_uris']; + + return client; + } + + static async get(id: string): Promise { + const client = await Client.findById(id); + return await this.getCredentials(client); + } + + static async findByClientId(clientId: string): Promise { + const client = await Client.findOne({ clientId }); + return await this.getCredentials(client); + } + + static async isAllowedOrigin(origin: string) { + return Client.exists({ origins: origin }); + } + + static async findByQuery(query: { poolId: string }, page = 1, limit = 10) { + return paginatedResults(Client, page, limit, query); + } + + static async create(sub: string, poolId: string, payload: TClientPayload, name?: string) { + const { data } = await authClient({ + method: 'POST', + url: '/reg', + headers: { + Authorization: `Bearer ${INITIAL_ACCESS_TOKEN}`, + }, + data: payload, + }); + + const client = await Client.create({ + sub, + name, + poolId, + grantType: payload.grant_types[0], + clientId: data.client_id, + registrationAccessToken: data.registration_access_token, + }); + + if (payload.request_uris && payload.request_uris.length) { + const origins = payload.request_uris.map((uri: string) => new URL(uri)); + await client.updateOne({ origins }); + } + + client.clientSecret = data['client_secret']; + client.requestUris = data['request_uris']; + + return client; + } + + static async remove(clientId: string) { + const client = await Client.findOne({ clientId }); + + await authClient({ + method: 'DELETE', + url: `/reg/${client.clientId}?access_token=${client.registrationAccessToken}`, + }); + + await client.deleteOne(); + } + + static async update(clientId: string, updates: TClientUpdatePayload) { + const client = await Client.findOne({ clientId }); + await client.updateOne(updates); + return this.getCredentials(client); + } +} + +export type TClientUpdatePayload = Partial<{ + name: string; +}>; diff --git a/apps/api/src/app/proxies/DiscordDataProxy.ts b/apps/api/src/app/proxies/DiscordDataProxy.ts new file mode 100644 index 000000000..f5a074ffd --- /dev/null +++ b/apps/api/src/app/proxies/DiscordDataProxy.ts @@ -0,0 +1,156 @@ +import axios, { AxiosRequestConfig } from 'axios'; +import { client, PermissionFlagsBits } from '../../discord'; +import { DiscordGuild, DiscordGuildDocument, PoolDocument } from '@thxnetwork/api/models'; +import { ActionRowBuilder, ButtonBuilder, Guild } from 'discord.js'; +import { WIDGET_URL } from '../config/secrets'; +import { logger } from '../util/logger'; +import { AccessTokenKind, OAuthRequiredScopes } from '@thxnetwork/common/enums'; +import { DISCORD_API_ENDPOINT } from '@thxnetwork/common/constants'; +import AccountProxy from './AccountProxy'; +import { discordColorToHex } from '../util/discord'; + +export enum NotificationVariant { + QuestDaily = 0, + QuestInvite = 1, + QuestYouTube = 3, + QuestTwitter = 4, + QuestDiscord = 5, + QuestCustom = 6, + QuestWeb3 = 7, +} + +export async function discordClient(config: AxiosRequestConfig) { + try { + const client = axios.create({ ...config, baseURL: DISCORD_API_ENDPOINT }); + return await client(config); + } catch (error) { + console.error(error); + } +} + +export default class DiscordDataProxy { + static async sendChannelMessage( + pool: PoolDocument, + content: string, + embeds: TDiscordEmbed[] = [], + buttons?: TDiscordButton[], + ) { + try { + const discordGuild = await DiscordGuild.findOne({ poolId: String(pool._id) }); + const url = WIDGET_URL + `/c/${pool.settings.slug}/quests`; + + if (discordGuild && discordGuild.channelId) { + const channel: any = await client.channels.fetch(discordGuild.channelId); + const components = []; + if (buttons) components.push(this.createButtonActionRow(buttons)); + + const botMember = channel.guild.members.cache.get(client.user.id); + if (!botMember.permissionsIn(channel).has(PermissionFlagsBits.SendMessages)) { + throw new Error('Insufficient channel permissions for bot to send messages.'); + } + + channel.send({ content, embeds, components }); + } else if (pool.settings.discordWebhookUrl) { + // Extending the content with a link as we're not allowed to send button components over webhooks + content += ` [Complete Quest ▸](<${url}>)`; + axios.post(pool.settings.discordWebhookUrl, { content, embeds }); + } + } catch (error) { + logger.error(error); + } + } + + static createButtonActionRow(buttons: TDiscordButton[]) { + const components = buttons.map((btn: TDiscordButton) => { + const button = new ButtonBuilder().setLabel(btn.label).setStyle(btn.style); + if (btn.customId) button.setCustomId(btn.customId); + if (btn.url) button.setURL(btn.url); + if (btn.emoji) button.setEmoji(btn.emoji); + if (btn.disabled) button.setDisabled(true); + return button; + }); + return new ActionRowBuilder().addComponents(components); + } + + static async getGuilds(token: TToken) { + const r = await discordClient({ + method: 'GET', + url: '/users/@me/guilds', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token.accessToken}`, + }, + }); + return r.data; + } + + static async validateGuildJoined(account: TAccount, guildId: string) { + const token = await AccountProxy.getToken( + account, + AccessTokenKind.Discord, + OAuthRequiredScopes.DiscordValidateGuild, + ); + if (!token) return { result: false, reason: 'Could not find a Discord access_token for this account.' }; + + const guilds = await this.getGuilds(token); + const isUserJoinedGuild = guilds.find((guild) => guild.id === guildId); + if (isUserJoinedGuild) return { result: true, reason: '' }; + + return { result: false, reason: 'Discord: Your Discord account is not a member of this server.' }; + } + + static async validateGuildRole(account: TAccount, guildId: string, roleId: string) { + const token = await AccountProxy.getToken( + account, + AccessTokenKind.Discord, + OAuthRequiredScopes.DiscordValidateGuild, + ); + if (!token) return { result: false, reason: 'Could not find a Discord access_token for this account.' }; + + // Fetch guild from bot + const guild = await this.fetchGuild(guildId); + if (!guild) return { result: false, reason: 'THX Bot is not in the server.' }; + + // Check role for guild member + const member = await guild.members.fetch(token.userId); + console.log(member.roles.cache.has(roleId), roleId); + if (member.roles.cache.has(roleId)) return { result: true, reason: '' }; + + return { result: false, reason: 'You do not have the required role.' }; + } + + static async getGuildRoles(guild: Guild) { + return guild.roles.cache.map((role) => ({ + id: role.id, + name: role.name, + color: discordColorToHex(role.color), + })); + } + + static async getGuildChannels(guild: Guild) { + const channels = await guild.channels.fetch(); + return channels.map((c) => ({ name: c.name, channelId: c.id })); + } + + static async fetchGuild(guildId: string) { + try { + return await client.guilds.fetch(guildId); + } catch (error) { + return; + } + } + + static async getGuild(guild: DiscordGuildDocument) { + const botGuild = await this.fetchGuild(guild.guildId); + const roles = botGuild ? await this.getGuildRoles(botGuild) : []; + const channels = botGuild ? await this.getGuildChannels(botGuild) : []; + + return { + ...guild, + roles, + channels, + isInvited: !!botGuild, + }; + } +} diff --git a/apps/api/src/app/proxies/TwitterDataProxy.ts b/apps/api/src/app/proxies/TwitterDataProxy.ts new file mode 100644 index 000000000..8b2780adc --- /dev/null +++ b/apps/api/src/app/proxies/TwitterDataProxy.ts @@ -0,0 +1,442 @@ +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { AccessTokenKind, OAuthRequiredScopes, OAuthTwitterScope } from '@thxnetwork/common/enums'; +import { TWITTER_API_ENDPOINT } from '@thxnetwork/common/constants'; +import { formatDistance } from 'date-fns'; +import { logger } from '../util/logger'; +import { TwitterLike } from '../models/TwitterLike'; +import { TwitterRepost } from '../models/TwitterRepost'; +import { TwitterUser } from '../models/TwitterUser'; +import { TwitterFollower } from '../models/TwitterFollower'; +import TwitterCacheService from '../services/TwitterCacheService'; +import AccountProxy from './AccountProxy'; + +async function twitterClient(config: AxiosRequestConfig) { + const client = axios.create({ ...config, baseURL: TWITTER_API_ENDPOINT }); + return await client(config); +} + +export default class TwitterDataProxy { + static async getUserByUsername(account: TAccount, username: string) { + const token = await AccountProxy.getToken(account, AccessTokenKind.Twitter, [ + OAuthTwitterScope.UsersRead, + OAuthTwitterScope.TweetRead, + ]); + if (!token) return { result: false, reason: 'X: Could not find a connection for this account.' }; + + const data = await this.request(token, { + method: 'GET', + url: `/users/by/username/${username}`, + params: { + 'user.fields': 'profile_image_url,public_metrics', + }, + }); + + return data.data; + } + + static async getUser(account: TAccount, userId: string) { + const token = await AccountProxy.getToken(account, AccessTokenKind.Twitter, [ + OAuthTwitterScope.UsersRead, + OAuthTwitterScope.TweetRead, + ]); + if (!token) return { result: false, reason: 'X: Could not find a connection for this account.' }; + + try { + const data = await this.request(token, { + method: 'GET', + url: `/users/${userId}`, + params: { + 'user.fields': 'profile_image_url,public_metrics', + }, + }); + + // Cache TwitterUser + await TwitterUser.findOneAndUpdate( + { userId: data.data.id }, + { + userId: data.data.id, + profileImgUrl: data.data.profile_image_url, + name: data.data.name, + username: data.data.username, + publicMetrics: { + followersCount: data.data.public_metrics.followers_count, + followingCount: data.data.public_metrics.following_count, + tweetCount: data.data.public_metrics.tweet_count, + listedCount: data.data.public_metrics.listed_count, + likeCount: data.data.public_metrics.like_count, + }, + }, + { upsert: true }, + ); + + return data.data; + } catch (res) { + return this.handleError(account, token, res); + } + } + + static async getTweet(account: TAccount, tweetId: string) { + const token = await AccountProxy.getToken(account, AccessTokenKind.Twitter, [ + OAuthTwitterScope.UsersRead, + OAuthTwitterScope.TweetRead, + ]); + if (!token) return { result: false, reason: 'X: Could not find a connection for this account.' }; + + const data = await this.request(token, { + method: 'GET', + url: `/tweets`, + params: { + ids: tweetId, + expansions: 'author_id', + }, + }); + + return { tweet: data.data[0], user: data.includes.users[0] }; + } + + /** + * '/tweets/search/recent' + * + * Rate Limits: + * Application-only: 450 requests per 15-minute window shared among all users of your app + * User context: 180 requests per 15-minute window per each authenticated user + */ + static async search( + account: TAccount, + query: string, + options: { + 'max_results': number; + 'media.fields': string; + 'tweet.fields': string; + 'user.fields': string; + 'expansions': string; + 'sort_order': string; + } = { + 'max_results': 10, + 'sort_order': 'recency', // relevancy / recency + 'expansions': 'author_id,attachments.media_keys', + 'tweet.fields': 'public_metrics,possibly_sensitive,created_at,attachments', + 'user.fields': 'public_metrics,username,profile_image_url,verified,verified_type', + 'media.fields': 'public_metrics,url,preview_image_url,width,height,alt_text', + }, + ) { + const token = await AccountProxy.getToken(account, AccessTokenKind.Twitter, [ + OAuthTwitterScope.UsersRead, + OAuthTwitterScope.TweetRead, + ]); + try { + logger.info(`Twitter Query: "${query}"`); + + const data = await this.request(token, { + url: '/tweets/search/recent', + method: 'GET', + params: { query, ...options }, + }); + logger.info(`Twitter Query Results: ${data.meta.result_count}`); + if (!data.meta.result_count) return []; + + return data.data.map((post) => { + const user = data.includes.users.find((user) => user.id === post.author_id); + const media = + post.attachments && + post.attachments.media_keys && + post.attachments.media_keys.map((key) => data.includes.media.find((m) => m.media_key === key)); + return { + user, + media, + ...post, + }; + }); + } catch (error) { + logger.error(error); + return []; + } + } + + static async searchTweets(account: TAccount, query: string) { + const token = await AccountProxy.getToken(account, AccessTokenKind.Twitter, [ + OAuthTwitterScope.UsersRead, + OAuthTwitterScope.TweetRead, + ]); + const startTime = new Date(Date.now() - 60 * 60 * 24).toISOString(); // 24h ago + const data = await this.request(token, { + url: '/tweets/search/recent', + method: 'GET', + params: { + query: `from:${token.userId} ${query}`, + start_time: startTime, + }, + }); + return data.data; + } + + static async validateUser(account: TAccount, quest: TQuestSocial) { + const token = await AccountProxy.getToken( + account, + AccessTokenKind.Twitter, + OAuthRequiredScopes.TwitterValidateUser, + ); + if (!token) return { result: false, reason: 'X: Could not find a connection for this account.' }; + + const metadata = JSON.parse(quest.contentMetadata); + const minFollowersCount = metadata.minFollowersCount ? Number(metadata.minFollowersCount) : 0; + + try { + const user = await this.getUser(account, token.userId); + + // Validate the follower count for this user + const followersCount = user.public_metrics.followers_count; + if (followersCount >= minFollowersCount) return { result: true, reason: '' }; + + return { + result: false, + reason: `X: Your account does not meet the threshold of ${minFollowersCount} followers.`, + }; + } catch (res) { + return this.handleError(account, token, res); + } + } + + static async validateFollow(account: TAccount, userId: string) { + const token = await AccountProxy.getToken( + account, + AccessTokenKind.Twitter, + OAuthRequiredScopes.TwitterValidateFollow, + ); + if (!token) return { result: false, reason: 'X: Could not find a connection for this account.' }; + try { + if (token.userId === userId) { + return { result: false, reason: 'X: Can not validate a follow for your account with your account.' }; + } + + const data = await this.request(token, { + url: `/users/${token.userId}/following`, + method: 'POST', + data: { + target_user_id: userId, + }, + }); + + // Cache TwitterFollower here if isFollowing is true + await TwitterFollower.findOneAndUpdate( + { userId: token.userId, targetUserId: userId }, + { userId: token.userId, targetUserId: userId }, + { upsert: true }, + ); + + if (data.data.following) { + return { result: true, reason: '' }; + } + + return { result: false, reason: 'X: Account is not found as a follower.' }; + } catch (res) { + return this.handleError(account, token, res); + } + } + + static async validateLike(account: TAccount, quest: TQuestSocial) { + const postId = quest.content; + const token = await AccountProxy.getToken( + account, + AccessTokenKind.Twitter, + OAuthRequiredScopes.TwitterValidateLike, + ); + if (!token) return { result: false, reason: 'X: Could not find a connection for this account.' }; + + // Search for TwitterLikes with this userId and postId in the cache + const like = await TwitterLike.findOne({ userId: token.userId, postId }); + if (like) { + logger.info( + `[${quest.poolId}][${account.sub}] X Quest ${quest._id} Like verification resolves from cache.`, + ); + return { result: true, reason: '' }; + } + + try { + // No cache result means we should update the cache. + await TwitterCacheService.updateLikeCache(account, quest, token); + + // Search the database again after a complete cache update that is not rate limited + const like = await this.findLike(token.userId, postId); + if (like) return { result: true, reason: '' }; + + // Fail if nothing is found + return { result: false, reason: 'X: Post has not been not liked.' }; + } catch (res) { + // Search the database again after a partial cache update that threw an error + const like = await this.findLike(token.userId, postId); + if (like) return { result: true, reason: '' }; + + // If not found amongst the latest cache update then we show the rate limit error + return this.handleError(account, token, res); + } + } + + static async validateRetweet(account: TAccount, quest: TQuestSocial) { + const postId = quest.content; + const token = await AccountProxy.getToken( + account, + AccessTokenKind.Twitter, + OAuthRequiredScopes.TwitterValidateRepost, + ); + if (!token) return { result: false, reason: 'X: Could not find a connection for this account.' }; + + // Query cached TwitterReposts for this tweetId and userId + const repost = await TwitterRepost.findOne({ userId: token.userId, postId }); + if (repost) { + logger.info( + `[${quest.poolId}][${account.sub}] X Quest ${quest._id} Repost verification resolves from cache.`, + ); + return { result: true, reason: '' }; + } + + try { + // No cache result means we should update the cache. + await TwitterCacheService.updateRepostCache(account, quest, token); + + // Search the database again after a complete cache update that is not rate limited + const repost = await this.findRepost(token.userId, postId); + if (repost) return { result: true, reason: '' }; + + // Fail if nothing is found + return { result: false, reason: 'X: Post has not been not reposted.' }; + } catch (res) { + // Search the database again after a partial cache update that threw an error + const repost = await this.findRepost(token.userId, postId); + if (repost) return { result: true, reason: '' }; + + return this.handleError(account, token, res); + } + } + + static async validateQuery(account: TAccount, quest: TQuestSocial) { + const token = await AccountProxy.getToken( + account, + AccessTokenKind.Twitter, + OAuthRequiredScopes.TwitterValidateMessage, + ); + if (!token) return { result: false, reason: 'X: Could not find a connection for this account.' }; + if (!token.metadata || !token.metadata.username) { + return { result: false, reason: 'X: Could not find your username. Please reconnect your X account.' }; + } + + // Check connected X account username is known + const { operators } = JSON.parse(quest.contentMetadata); + if (!token.metadata || !token.metadata.username) { + return { result: false, reason: 'X: Could not find your username. Please reconnect your X account.' }; + } + + // Check if account username is among the required post authors if this operator is available + const authorWhitelist = operators.from.map((author) => author); + const username = token.metadata.username.toLowerCase(); + if (operators.from.length && !authorWhitelist.includes(username)) { + return { + result: false, + reason: `X: Your X account @${token.metadata.username} is not whitelisted for this quest.`, + }; + } + + try { + // If there is an author requirement we do not add the from operator for the current user + // and search for the given list instead + const query = operators.from.length ? quest.content : `from:${username} ${quest.content}`; + + // Not checking the cache here on purpose as we would need to reverse engineer + // the query logic in order to find matched similar to how X would + const posts = await this.search(account, query); + if (!posts.length) { + return { + result: false, + reason: `X: Could not find a post matching the requirements in the last 7 days.`, + }; + } + + // Cache the posts for future reference and display in UI + await TwitterCacheService.savePosts(posts); + + // Check if account username is among the results + const authorUsernames = posts.map((result) => result.user.username.toLowerCase()); + if (!authorUsernames.includes(token.metadata.username.toLowerCase())) { + return { + result: false, + reason: `X: Your X account @${token.metadata.username} is not found among the matched posts.`, + }; + } + + return { result: true, reason: '' }; + } catch (res) { + return this.handleError(account, token, res); + } + } + + static findLike(userId: string, postId: string) { + return TwitterLike.findOne({ userId, postId }); + } + + static findRepost(userId: string, postId: string) { + return TwitterRepost.findOne({ userId, postId }); + } + + static async request(token: TToken, config: AxiosRequestConfig) { + try { + const { data } = await twitterClient({ + ...config, + headers: { Authorization: `Bearer ${token.accessToken}` }, + }); + return data; + } catch (error) { + if (error.response) { + // Rethrow if this is an axios error + throw error.response; + } else { + logger.error(error); + } + } + } + + static async handleError(account: TAccount, token: TToken, res: AxiosResponse) { + if (res.status === 429) { + logger.info(`[429] X-RateLimit is hit by account ${account.sub} with X UserId ${token.userId}.`); + return this.handleRateLimitError(res); + } + + if (res.status === 401) { + logger.info(`[401] Token for ${account.sub} with X UserId ${token.userId} is invalid and disconnected.`); + await AccountProxy.disconnect(account, token.kind); + return { result: false, reason: 'Your X account connection has been removed, please reconnect!' }; + } + + if (res.status === 403) { + logger.info(`[403] Token for ${account.sub} with X UserId ${token.userId} has insufficient permissions.`); + return { result: false, reason: 'Your X account access level is insufficient, please reconnect!' }; + } + + logger.error(res); + + return { result: false, reason: 'X: An unexpected issue occured during your request.' }; + } + + private static handleRateLimitError(res: AxiosResponse) { + const limit = res.headers['x-rate-limit-limit']; + const resetTime = Number(res.headers['x-rate-limit-reset']); + const seconds = resetTime - Math.ceil(Date.now() / 1000); + + return { + result: false, + reason: `Quest requirement not found yet! We can only check ${ + limit * 100 + } items every 15 minutes. Please wait ${formatDistance(0, seconds * 1000, { + includeSeconds: true, + })} before retrying. Thank you!`, + }; + } + + private static parseSearchQuery(content: string) { + const emojiRegex = /|\p{Extended_Pictographic}/gu; + return content + .split(emojiRegex) + .filter((text) => text && text.length > 1 && !text.match(emojiRegex)) + .map((text) => `"${text}"`) + .join(' '); + } +} diff --git a/apps/api/src/app/proxies/YoutubeDataProxy.ts b/apps/api/src/app/proxies/YoutubeDataProxy.ts new file mode 100644 index 000000000..482c7e7c8 --- /dev/null +++ b/apps/api/src/app/proxies/YoutubeDataProxy.ts @@ -0,0 +1,53 @@ +import { google } from 'googleapis'; +import { AccessTokenKind, OAuthRequiredScopes, OAuthScope } from '@thxnetwork/common/enums'; +import { AUTH_URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET } from '../config/secrets'; +import AccountProxy from './AccountProxy'; + +const client = new google.auth.OAuth2(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, AUTH_URL + '/oidc/callback/google'); + +google.options({ auth: client }); + +async function getClient(account: TAccount, requiredScopes: OAuthScope[]) { + const token = await AccountProxy.getToken(account, AccessTokenKind.Google, requiredScopes); + client.setCredentials({ + access_token: token.accessToken, + refresh_token: token.refreshToken, + }); + return google.youtube({ version: 'v3' }); +} + +export default class YoutubeDataProxy { + static async validateLike(account: TAccount, videoId: string, nextPageToken?: string) { + const youtube = await getClient(account, OAuthRequiredScopes.GoogleYoutubeLike); + const { data } = await youtube.videos.list({ + part: ['snippet'], + myRating: 'like', + maxResults: 50, + pageToken: nextPageToken, + }); + + const isLiked = data.items.find((item) => item.id === videoId); + if (isLiked) return { result: true, reason: '' }; + + // NOTE Disabled paging as we hit rate limits when searching + // through all liked videos of a user. + // if (data.nextPageToken) { + // return await this.validateLike(account, content, nextPageToken); + // } + + return { result: false, reason: 'YouTube: Could not find your like for this video.' }; + } + + static async validateSubscribe(account: TAccount, channelId: string) { + const youtube = await getClient(account, OAuthRequiredScopes.GoogleYoutubeLike); + const { data } = await youtube.subscriptions.list({ + forChannelId: channelId, + part: ['snippet'], + mine: true, + }); + const isSubscribed = data.items.length > 0; + if (isSubscribed) return { result: true, reason: '' }; + + return { result: false, reason: 'Could not find your subscription for this channel.' }; + } +} diff --git a/apps/api/src/app/services/AnalyticsService.ts b/apps/api/src/app/services/AnalyticsService.ts new file mode 100644 index 000000000..297f57ce8 --- /dev/null +++ b/apps/api/src/app/services/AnalyticsService.ts @@ -0,0 +1,552 @@ +import mongoose from 'mongoose'; +import { RewardCoinDocument, RewardCoin } from '@thxnetwork/api/models/RewardCoin'; +import { RewardNFTDocument, RewardNFT } from '@thxnetwork/api/models/RewardNFT'; +import { QuestInviteDocument, QuestInvite } from '@thxnetwork/api/models/QuestInvite'; +import { + PoolDocument, + RewardCustomDocument, + QuestCustomDocument, + QuestSocialDocument, + QuestWeb3Document, + QuestWeb3, + QuestSocialEntry, + QuestInviteEntry, + QuestWeb3Entry, + RewardCoinPayment, + RewardNFTPayment, + WalletDocument, + Participant, + RewardCouponDocument, + RewardCustom, + RewardDiscordRoleDocument, + RewardCouponPayment, + RewardCustomPayment, + RewardDiscordRolePayment, + RewardCoupon, + RewardDiscordRole, + QuestCustomEntry, + QuestDailyEntry, + QuestCustom, + QuestSocial, + QuestDailyDocument, + QuestDaily, + QuestGitcoin, + QuestGitcoinEntry, + Wallet, + RewardGalachainDocument, + RewardGalachain, + QuestGitcoinDocument, + RewardGalachainPayment, +} from '@thxnetwork/api/models'; + +async function getPoolAnalyticsForChart(pool: PoolDocument, startDate: Date, endDate: Date) { + // Rewards + const [ + erc20PerksQueryResult, + erc721PerksQueryResult, + customRewardsQueryResult, + couponRewardsQueryResult, + discordRoleRewardsQueryResult, + galachainRewardsQueryResult, + ] = await Promise.all([ + queryRewardRedemptions({ + collectionName: 'rewardcoinpayment', + key: 'rewardId', + model: RewardCoin, + poolId: String(pool._id), + startDate, + endDate, + }), + queryRewardRedemptions({ + collectionName: 'rewardnftpayment', + key: 'rewardId', + model: RewardNFT, + poolId: String(pool._id), + startDate, + endDate, + }), + queryRewardRedemptions({ + collectionName: 'rewardcustompayment', + key: 'rewardId', + model: RewardCustom, + poolId: String(pool._id), + startDate, + endDate, + }), + queryRewardRedemptions({ + collectionName: 'rewardcouponpayment', + key: 'rewardId', + model: RewardCoupon, + poolId: String(pool._id), + startDate, + endDate, + }), + queryRewardRedemptions({ + collectionName: 'rewarddiscordrolepayment', + key: 'rewardId', + model: RewardDiscordRole, + poolId: String(pool._id), + startDate, + endDate, + }), + queryRewardRedemptions({ + collectionName: 'rewargalachainpayment', + key: 'rewardId', + model: RewardGalachain, + poolId: String(pool._id), + startDate, + endDate, + }), + ]); + + // Quests + const [ + milestoneRewardsQueryResult, + referralRewardsQueryResult, + pointRewardsQueryResult, + dailyRewardsQueryResult, + web3QuestsQueryResult, + gitcoinQuestsQueryResult, + ] = await Promise.all([ + queryQuestEntries({ + collectionName: 'questcustomentry', + key: 'questId', + model: QuestCustom, + poolId: String(pool._id), + startDate, + endDate, + extraFilter: { isClaimed: true }, + }), + queryQuestEntries({ + collectionName: 'questinviteentry', + key: 'questId', + model: QuestInvite, + poolId: String(pool._id), + startDate, + endDate, + extraFilter: { isApproved: true }, + }), + queryQuestEntries({ + collectionName: 'questsocialentry', + key: 'questId', + model: QuestSocial, + poolId: String(pool._id), + startDate, + endDate, + }), + queryQuestEntries({ + collectionName: 'questdailyentry', + key: 'questId', + model: QuestDaily, + poolId: String(pool._id), + startDate, + endDate, + }), + queryQuestEntries({ + collectionName: 'questweb3entry', + key: 'questId', + model: QuestWeb3, + poolId: String(pool._id), + startDate, + endDate, + }), + queryQuestEntries({ + collectionName: 'questgitcoinentry', + key: 'questId', + model: QuestGitcoin, + poolId: String(pool._id), + startDate, + endDate, + }), + ]); + + const result = { + _id: pool._id, + erc20Perks: erc20PerksQueryResult.map((x) => { + return { + day: x._id, + totalAmount: x.total_amount, + }; + }), + erc721Perks: erc721PerksQueryResult.map((x) => { + return { + day: x._id, + totalAmount: x.total_amount, + }; + }), + customRewards: customRewardsQueryResult.map((x) => { + return { + day: x._id, + totalAmount: x.total_amount, + }; + }), + couponRewards: couponRewardsQueryResult.map((x) => { + return { + day: x._id, + totalAmount: x.total_amount, + }; + }), + discordRoleRewards: discordRoleRewardsQueryResult.map((x) => { + return { + day: x._id, + totalAmount: x.total_amount, + }; + }), + galachainRewards: galachainRewardsQueryResult.map((x) => { + return { + day: x._id, + totalAmount: x.total_amount, + }; + }), + // + dailyRewards: dailyRewardsQueryResult.map((x) => { + return { + day: x._id, + totalAmount: x.total_amount, + }; + }), + milestoneRewards: milestoneRewardsQueryResult.map((x) => { + return { + day: x._id, + totalAmount: x.total_amount, + }; + }), + referralRewards: referralRewardsQueryResult.map((x) => { + return { + day: x._id, + totalAmount: x.total_amount, + }; + }), + pointRewards: pointRewardsQueryResult.map((x) => { + return { + day: x._id, + totalAmount: x.total_amount, + }; + }), + web3Quests: web3QuestsQueryResult.map((x) => { + return { + day: x._id, + totalAmount: x.total_amount, + }; + }), + gitcoinQuests: gitcoinQuestsQueryResult.map((x) => { + return { + day: x._id, + totalAmount: x.total_amount, + }; + }), + }; + return result; +} + +async function getPoolMetrics(pool: PoolDocument, dateRange?: { startDate: Date; endDate: Date }) { + const collections = [ + QuestDailyEntry, + QuestSocialEntry, + QuestInviteEntry, + QuestCustomEntry, + QuestWeb3Entry, + QuestGitcoinEntry, + RewardCoinPayment, + RewardNFTPayment, + RewardCustomPayment, + RewardCouponPayment, + RewardDiscordRolePayment, + RewardGalachainPayment, + ]; + const [ + dailyQuest, + socialQuest, + inviteQuest, + customQuest, + web3Quest, + gitcoinQuest, + coinReward, + nftReward, + customReward, + couponReward, + discordRoleReward, + galachainReward, + ] = await Promise.all( + collections.map(async (Model) => { + const $match = { poolId: String(pool._id) }; + if (dateRange) { + $match['createdAt'] = { $gte: dateRange.startDate, $lte: dateRange.endDate }; + } + + // Extend the $match filter with model specific properties + switch (Model) { + case QuestDailyEntry: + $match['state'] = 1; + break; + case QuestCustomEntry: + $match['isClaimed'] = true; + break; + } + + const [result] = await Model.aggregate([ + { $match }, + { + $group: { + _id: '$poolId', + totalCompleted: { $sum: 1 }, + totalAmount: { $sum: { $convert: { input: '$amount', to: 'int' } } }, + }, + }, + ]); + + const query = { poolId: String(pool._id) }; + if (dateRange) { + query['createdAt'] = { $gte: dateRange.startDate, $lte: dateRange.endDate }; + } + const totalCreated = await Model.countDocuments(query as any); + + return { + totalCompleted: result && result.totalCompleted ? result.totalCompleted : 0, + totalAmount: result && result.totalAmount ? result.totalAmount : 0, + totalCreated, + }; + }), + ); + + return { + dailyQuest, + socialQuest, + inviteQuest, + customQuest, + web3Quest, + gitcoinQuest, + coinReward, + nftReward, + customReward, + couponReward, + discordRoleReward, + galachainReward, + }; +} + +async function createLeaderboard(pool: PoolDocument, dateRange?: { startDate: Date; endDate: Date }) { + const collections = [ + QuestDailyEntry, + QuestSocialEntry, + QuestInviteEntry, + QuestCustomEntry, + QuestWeb3Entry, + QuestGitcoinEntry, + ]; + const result = await Promise.all( + collections.map(async (Model) => { + const $match = { poolId: String(pool._id) }; + + // Extend the $match filter with optional dateRange + if (dateRange) { + $match['createdAt'] = { $gte: dateRange.startDate, $lte: dateRange.endDate }; + } + + // Extend the $match filter with model specific properties + switch (Model) { + case QuestDailyEntry: + $match['state'] = 1; + break; + case QuestCustomEntry: + $match['isClaimed'] = true; + break; + } + + const $group = { + _id: '$sub', + totalCompleted: { $sum: 1 }, + totalAmount: { $sum: { $convert: { input: '$amount', to: 'int' } } }, + }; + + return await Model.aggregate([{ $match }, { $group }]); + }), + ); + + // Combine results from all collections and calculate overall totals + const walletTotals = {}; + for (const collectionResults of result) { + for (const r of collectionResults) { + if (!r) continue; + if (walletTotals[r._id]) { + walletTotals[r._id].totalCompleted += r.totalCompleted; + walletTotals[r._id].totalAmount += r.totalAmount; + } else { + walletTotals[r._id] = { + totalCompleted: r.totalCompleted, + totalAmount: r.totalAmount, + }; + } + } + } + + const wallets = await Wallet.find({ _id: Object.keys(walletTotals), sub: { $exists: true } }); + const leaderboard = wallets + .map((wallet: WalletDocument) => ({ + score: walletTotals[wallet._id].totalAmount || 0, + questEntryCount: walletTotals[wallet._id].totalCompleted || 0, + sub: wallet.sub, + })) + .filter((entry) => entry.score > 0) + .sort((a: any, b: any) => b.score - a.score); + + const updates = leaderboard.map( + (entry: { sub: string; score: number; questEntryCount: number }, index: number) => ({ + updateOne: { + filter: { poolId: String(pool._id), sub: entry.sub }, + update: { + $set: { + rank: Number(index) + 1, + score: entry.score, + questEntryCount: entry.questEntryCount, + }, + }, + }, + }), + ); + + await Participant.bulkWrite(updates); +} + +async function queryQuestEntries(args: { + model: mongoose.Model; + poolId: string; + collectionName: string; + key: string; + startDate: Date; + endDate: Date; + extraFilter?: object; +}) { + const extraFilter = args.extraFilter ? { ...args.extraFilter } : {}; + const queryResult = await args.model.aggregate([ + { + $match: { + poolId: args.poolId, + }, + }, + { + $lookup: { + from: args.collectionName, + let: { + id: { + $convert: { + input: '$_id', + to: 'string', + }, + }, + }, + pipeline: [ + { + $match: { + $and: [ + { + $expr: { + $eq: ['$$id', `$${args.key}`], + }, + }, + { + createdAt: { + $gte: args.startDate, + $lte: args.endDate, + }, + }, + extraFilter, + ], + }, + }, + ], + as: 'entries', + }, + }, + { + $unwind: '$entries', + }, + { + $group: { + _id: { + $dateToString: { + format: '%Y-%m-%d', + date: { $toDate: '$entries.createdAt' }, + }, + }, + total_amount: { + $sum: 1, + }, + }, + }, + ]); + + return queryResult; +} + +async function queryRewardRedemptions(args: { + model: mongoose.Model; + poolId: string; + collectionName: string; + key: string; + startDate: Date; + endDate: Date; + extraFilter?: object; +}) { + const extraFilter = args.extraFilter ? { ...args.extraFilter } : {}; + const queryResult = await args.model.aggregate([ + { + $match: { + poolId: args.poolId, + }, + }, + { + $lookup: { + from: args.collectionName, + let: { + id: { + $convert: { + input: '$_id', + to: 'string', + }, + }, + }, + pipeline: [ + { + $match: { + $and: [ + { + $expr: { + $eq: ['$$id', `$${args.key}`], + }, + }, + { + createdAt: { + $gte: args.startDate, + $lte: args.endDate, + }, + }, + extraFilter, + ], + }, + }, + ], + as: 'entries', + }, + }, + { + $unwind: '$entries', + }, + { + $group: { + _id: { + $dateToString: { + format: '%Y-%m-%d', + date: { $toDate: '$entries.createdAt' }, + }, + }, + total_amount: { + $sum: 1, + }, + }, + }, + ]); + + return queryResult; +} +export default { getPoolMetrics, createLeaderboard, getPoolAnalyticsForChart }; diff --git a/apps/api/src/app/services/BalancerService.ts b/apps/api/src/app/services/BalancerService.ts new file mode 100644 index 000000000..6ab968ac1 --- /dev/null +++ b/apps/api/src/app/services/BalancerService.ts @@ -0,0 +1,241 @@ +import axios from 'axios'; +import { BalancerSDK, Network } from '@balancer-labs/sdk'; +import { BALANCER_POOL_ID, ETHEREUM_RPC, HARDHAT_RPC, NODE_ENV, POLYGON_RPC } from '../config/secrets'; +import { logger } from '../util/logger'; +import { WalletDocument } from '../models'; +import { ChainId } from '@thxnetwork/common/enums'; +import { contractArtifacts, contractNetworks } from '@thxnetwork/api/contracts'; +import { BigNumber, ethers } from 'ethers'; +import { formatUnits } from 'ethers/lib/utils'; + +class BalancerService { + pricing = {}; + apr = { + [ChainId.Hardhat]: { + balancer: { + min: 0, + max: 0, + swapFees: 0, + }, + thx: 0, + }, + [ChainId.Polygon]: { + balancer: { + min: 0, + max: 0, + swapFees: 0, + }, + thx: 0, + }, + }; + tvl = { + [ChainId.Hardhat]: { liquidity: '0', staked: '0', tvl: '0' }, + [ChainId.Polygon]: { liquidity: '0', staked: '0', tvl: '0' }, + }; + rewards = { + [ChainId.Hardhat]: { bal: '0', bpt: '0' }, + [ChainId.Polygon]: { bal: '0', bpt: '0' }, + }; + schedule = { + [ChainId.Hardhat]: { bal: [], bpt: [] }, + [ChainId.Polygon]: { bal: [], bpt: [] }, + }; + balancer = new BalancerSDK({ + network: Network.POLYGON, + rpcUrl: POLYGON_RPC, + }); + + constructor() { + this.updatePricesJob().then(() => { + this.updateMetricsJob(); + }); + } + + async buildJoin( + wallet: WalletDocument, + usdcAmountInWei: string, + thxAmountInWei: string, + slippage: string, + ): Promise { + const pool = await this.balancer.pools.find(BALANCER_POOL_ID); + const [usdc, thx] = pool.tokens as { + address: string; + }[]; + + return pool.buildJoin( + wallet.address, + [usdc.address, thx.address], + [usdcAmountInWei, thxAmountInWei], + slippage, + ) as JoinPoolAttributes; + } + + getPricing() { + return this.pricing; + } + + getMetrics(chainId: ChainId) { + return { + apr: this.apr[chainId], + tvl: this.tvl[chainId], + rewards: this.rewards[chainId], + schedule: this.schedule[chainId], + }; + } + + async fetchPrice(symbolIn: string, symbolOut: string) { + try { + const { data } = await axios({ + method: 'GET', + url: `https://api.coinbase.com/v2/exchange-rates?currency=${symbolIn}`, + }); + + return data.data.rates[symbolOut]; + } catch (error) { + logger.error(error); + return 0; + } + } + + async updatePricesJob() { + const pool = await this.balancer.pools.find(BALANCER_POOL_ID); + const [usdc, thx] = pool.tokens as unknown as { + symbol: string; + balance: number; + token: { latestUSDPrice: number }; + }[]; + const totalShares = pool.totalShares as unknown as number; + const thxValue = thx.balance * thx.token.latestUSDPrice; + const usdcValue = usdc.balance * usdc.token.latestUSDPrice; + const btpPrice = (thxValue + usdcValue) / totalShares; + const balPrice = await this.fetchPrice('BAL', 'USDC'); + + this.pricing = { + '20USDC-80THX': btpPrice, + 'BAL': Number(balPrice), + 'USDC': Number(usdc.token.latestUSDPrice), + 'THX': Number(thx.token.latestUSDPrice), + }; + } + + async updateMetricsJob() { + const rpcMap = { [ChainId.Hardhat]: HARDHAT_RPC, [ChainId.Polygon]: POLYGON_RPC }; + const priceOfBAL = this.pricing['BAL']; + const pricePerBPT = this.pricing['20USDC-80THX']; + + // Amount of bpt-gauge locked in veTHX in wei + for (const chainId of [ChainId.Hardhat, ChainId.Polygon]) { + if (NODE_ENV === 'production' && chainId === ChainId.Hardhat) continue; + const provider = new ethers.providers.JsonRpcProvider(rpcMap[chainId]); + const gaugeAddress = contractNetworks[chainId].BPTGauge; + const bptAddress = contractNetworks[chainId].BPT; + const gauge = new ethers.Contract(gaugeAddress, contractArtifacts.BPTGauge.abi, provider); + const bpt = new ethers.Contract(bptAddress, contractArtifacts.BPT.abi, provider); + + // veTHX contract on Polygon + const veTHXAddress = contractNetworks[chainId].VotingEscrow; + const veTHX = new ethers.Contract(veTHXAddress, contractArtifacts.VotingEscrow.abi, provider); + + const { rewards, schedule } = await this.getRewards(chainId); + this.rewards[chainId] = rewards; + logger.debug(this.rewards[chainId]); + + this.schedule[chainId] = schedule; + logger.debug(this.schedule[chainId]); + + // TVL is measured as the total amount of BPT-gauge locked in veTHX + const liquidity = (await bpt.totalSupply()).toString(); + const staked = (await bpt.balanceOf(gauge.address)).toString(); + const tvl = (await gauge.balanceOf(veTHXAddress)).toString(); + this.tvl[chainId] = { liquidity, staked, tvl }; + logger.debug(this.tvl[chainId]); + + // Calc APR + const apr = await this.calculateBalancerAPR(gauge, priceOfBAL, pricePerBPT); + const balancer = { apr, swapFees: 0.2 }; // TODO Fetch swapFees from SDK or contract + const rewardsInBPT = this.rewards[chainId].bpt; + const thx = await this.calculateTHXAPR(gauge, veTHX, rewardsInBPT, pricePerBPT); + this.apr[chainId] = { balancer, thx }; + logger.debug(this.apr[chainId]); + } + + // Log pricing here because job interval creates less logging clutter + logger.debug(this.pricing); + } + + async calculateTHXAPR(gauge: ethers.Contract, veTHX: ethers.Contract, rewardsInBPT: string, pricePerBPT: number) { + const monthlyEmissions = Number(formatUnits(rewardsInBPT, 18)); + const totalShares = Number(formatUnits(await gauge.balanceOf(veTHX.address), 18)); + const pricePerShare = pricePerBPT; + return ((monthlyEmissions * 12) / totalShares / pricePerShare) * 100; + } + + async calculateBalancerAPR(gauge: ethers.Contract, priceOfBAL: number, pricePerBPT: number) { + // Balancer Gauge contracts on Ethereum + const ethereumProvider = new ethers.providers.JsonRpcProvider(ETHEREUM_RPC); + const gaugeControllerAddress = contractNetworks[ChainId.Ethereum].BalancerGaugeController; + const gaugeController = new ethers.Contract( + gaugeControllerAddress, + contractArtifacts.BalancerGaugeController.abi, + ethereumProvider, + ); + const rootGaugeAddress = contractNetworks[ChainId.Ethereum].BalancerRootGauge; + + // APR formula inputs + const gaugeRelWeight = Number((await gaugeController.gauge_relative_weight(rootGaugeAddress)).toString()); + const workingSupply = Number((await gauge.working_supply()).toString()); + + // Take Balancer inflation schedule into account. Started at 140000 BAL per week + // https://docs.balancer.fi/concepts/governance/bal-token.html#supply-inflation-schedule + const weeklyBALemissions = 102530.48; // TODO add formula to calculate weekly emissions + + // APR formula as per + // https://docs.balancer.fi/reference/vebal-and-gauges/apr-calculation.html + + // Example data May 4th 2024 + // const workingSupply = 8.102148903933154e23; + // const gaugeRelWeight = 1518354055844830; + // const weeklyBALemissions = 102530.48; + // const priceOfBAL = 3.655; + // const pricePerBPT = 0.04489925552408662; + + return ( + (((0.4 / (workingSupply + 0.4)) * gaugeRelWeight * weeklyBALemissions * 52 * priceOfBAL) / pricePerBPT) * + 100 + ); + } + + async getRewards(chainId: ChainId) { + const rpcMap = { + [ChainId.Hardhat]: HARDHAT_RPC, + [ChainId.Polygon]: POLYGON_RPC, + }; + const { BAL, BPT, RewardFaucet, RewardDistributor } = contractNetworks[chainId]; + const provider = new ethers.providers.JsonRpcProvider(rpcMap[chainId]); + const rewardFaucet = new ethers.Contract(RewardFaucet, contractArtifacts['RewardFaucet'].abi, provider); + const amountOfWeeks = '4'; + const [balSchedule, bptSchedule] = await Promise.all( + [BAL, BPT].map(async (tokenAddress: string) => { + const upcoming = await rewardFaucet.getUpcomingRewardsForNWeeks(tokenAddress, amountOfWeeks); + return upcoming.map((amount: BigNumber) => amount.toString()); + }), + ); + const [balTotal, bptTotal] = await Promise.all( + [BAL, BPT].map(async (tokenAddress) => await rewardFaucet.totalTokenRewards(tokenAddress)), + ); + + // Add reward distributor BAL balance to the current week + const balContract = new ethers.Contract(BAL, contractArtifacts['BAL'].abi, provider); + const balBalance = await balContract.balanceOf(RewardDistributor); + balSchedule[0] = BigNumber.from(balSchedule[0]).add(balBalance).toString(); + + return { + schedule: { bal: balSchedule, bpt: bptSchedule }, + rewards: { bal: balTotal.add(balBalance).toString(), bpt: bptTotal.toString() }, + }; + } +} + +const service = new BalancerService(); + +export default service; diff --git a/apps/api/src/app/services/BrandService.ts b/apps/api/src/app/services/BrandService.ts new file mode 100644 index 000000000..bbcc60637 --- /dev/null +++ b/apps/api/src/app/services/BrandService.ts @@ -0,0 +1,10 @@ +import { Brand } from '@thxnetwork/api/models/Brand'; + +export default { + get: async (poolId: string) => { + return Brand.findOne({ poolId }); + }, + update: async (filter: Partial, updates: Partial) => { + return Brand.findOneAndUpdate(filter, updates, { upsert: true, new: true }); + }, +}; diff --git a/apps/api/src/app/services/CanvasService.ts b/apps/api/src/app/services/CanvasService.ts new file mode 100644 index 000000000..e60be467c --- /dev/null +++ b/apps/api/src/app/services/CanvasService.ts @@ -0,0 +1,79 @@ +import path from 'path'; +import { createCanvas, loadImage, registerFont } from 'canvas'; +import { assetsPath } from '@thxnetwork/api/util/path'; + +// Load on boot as registration on runtime results in font not being loaded in time +const fontPath = path.resolve(assetsPath, 'fa-solid-900.ttf'); +const family = 'Font Awesome 5 Pro Solid'; +const defaultBackgroundImgPath = path.resolve(assetsPath, 'bg.png'); +const defaultLogoImgPath = path.resolve(assetsPath, 'logo.png'); + +registerFont(fontPath, { family, style: 'normal', weight: '900' }); + +function drawImageBg(canvas, ctx, image) { + const imageAspectRatio = image.width / image.height; + let scaledWidth = canvas.width + 1, + scaledHeight = canvas.width / imageAspectRatio; + + if (scaledHeight < canvas.height) { + scaledHeight = canvas.height; + scaledWidth = canvas.height * imageAspectRatio; + } + + const offsetX = Math.floor((canvas.width - scaledWidth) / 2); + const offsetY = Math.floor((canvas.height - scaledHeight) / 2); + + // Draw mask + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(canvas.width, 0); + ctx.lineTo(canvas.width, canvas.height); + ctx.lineTo(0, canvas.height); + ctx.lineTo(0, 0); + ctx.closePath(); + + ctx.clip(); + ctx.drawImage(image, offsetX, offsetY, scaledWidth, scaledHeight); + ctx.restore(); +} + +async function dataUrlToFile(dataUrl: string) { + return await loadImage(dataUrl); +} + +async function createPreviewImage({ logoImgUrl, backgroundImgUrl }: TBrand) { + const bg = await loadImage(backgroundImgUrl || defaultBackgroundImgPath); + const logo = await loadImage(logoImgUrl || defaultLogoImgPath); + + // Create a canvas with the desired dimensions + // https://www.linkedin.com/help/linkedin/answer/a521928/make-your-website-shareable-on-linkedin + const canvasWidth = 1200; + const canvasHeight = 627; + const canvas = createCanvas(canvasWidth, canvasHeight); + + const ctx = canvas.getContext('2d'); + + // Draw the loaded image onto the canvas + drawImageBg(canvas, ctx, bg); + + // Draw the logo + const logoRatio = logo.width / logo.height; + const logoWidth = 200; + const logoHeight = logoWidth / logoRatio; + + ctx.drawImage(logo, canvasWidth / 2 - logoWidth / 2, canvasHeight / 2 - logoHeight / 2, logoWidth, logoHeight); + + // Convert the canvas content to a buffer + // const dataUrl = canvas.toDataURL('image/png'); + const buffer = canvas.toBuffer('image/png'); + + return buffer; +} + +export default { + dataUrlToFile, + createPreviewImage, + defaultBackgroundImgPath, + defaultLogoImgPath, + drawImageBg, +}; diff --git a/apps/api/src/app/services/ClaimService.ts b/apps/api/src/app/services/ClaimService.ts new file mode 100644 index 000000000..5a8eeb73e --- /dev/null +++ b/apps/api/src/app/services/ClaimService.ts @@ -0,0 +1,50 @@ +import { QRCodeEntry } from '@thxnetwork/api/models'; +import { v4 } from 'uuid'; + +export default class QRCodeService { + static create(data: Partial, claimAmount: number) { + if (!claimAmount) return; + return QRCodeEntry.create(Array.from({ length: Number(claimAmount) }).map(() => ({ uuid: v4(), ...data }))); + } + + static findByReward(reward: TRewardNFT, page: number, limit: number) { + // + } +} + +// function create( +// data: { poolId: string; rewardUuid: string; erc20Id?: string; erc721Id?: string; erc1155Id?: string }, +// claimAmount: number, +// ) { +// if (!claimAmount) return; +// return QRCodeEntry.create( +// Array.from({ length: Number(claimAmount) }).map(() => ({ uuid: db.createUUID(), ...data })), +// ); +// } + +// function findByUuid(uuid: string) { +// return QRCodeEntry.findOne({ uuid }); +// } + +// function findByPool(pool: PoolDocument) { +// return QRCodeEntry.find({ poolId: String(pool._id) }); +// } + +// async function findByPerk(reward: TReward) { +// const claims = await QRCodeEntry.find({ rewardUuid: reward.uuid, poolId: reward.poolId }); +// const subs = claims.filter((c) => c.sub).map(({ sub }) => sub); +// const accounts = await AccountProxy.find({ subs }); + +// return claims +// .map((claim) => { +// return { ...claim.toJSON(), account: accounts.find((a) => claim.sub === a.sub) }; +// }) +// .sort((a, b) => { +// if (!a.sub && !b.sub) return 0; // Both are undefined, no change in order +// if (!a.sub) return -1; // a.sub is undefined, move it to the bottom +// if (!b.sub) return 1; // b.sub is undefined, move it to the bottom +// return a.sub.localeCompare(b.sub); // Sort by sub values +// }); +// } + +// export default { create, findByUuid, findByPool, findByPerk }; diff --git a/apps/api/src/app/services/ContractService.ts b/apps/api/src/app/services/ContractService.ts new file mode 100644 index 000000000..4528212e2 --- /dev/null +++ b/apps/api/src/app/services/ContractService.ts @@ -0,0 +1,52 @@ +import { ChainId } from '@thxnetwork/common/enums'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { AbiItem } from 'web3-utils'; +import { Contract } from 'web3-eth-contract'; +import { TokenContractName } from '@thxnetwork/api/contracts'; +import { SafeVersion } from '@safe-global/safe-core-sdk-types'; +import { contractArtifacts } from '@thxnetwork/api/contracts'; +import { ethers } from 'ethers'; + +export const safeVersion: SafeVersion = '1.3.0'; + +const getChainId = () => (process.env.NODE_ENV !== 'production' ? ChainId.Hardhat : ChainId.Polygon); +const getContract = (contractName: TokenContractName, chainId: ChainId, address: string) => { + const { signer } = getProvider(chainId); + return new ethers.Contract(address, contractArtifacts[contractName].abi, signer); +}; + +export const deploy = async (contractName: string, args: any[], signer: ethers.Signer): Promise => { + if (!contractArtifacts[contractName]) throw new Error('No artifact for contract name'); + const factory = new ethers.ContractFactory( + contractArtifacts[contractName].abi, + contractArtifacts[contractName].bytecode, + signer, + ); + return await factory.deploy(...args); +}; + +export const getContractFromAbi = (chainId: ChainId, abi: AbiItem[], address?: string): Contract => { + const { web3 } = getProvider(chainId); + return new web3.eth.Contract(abi, address) as unknown as Contract; +}; + +export const getAbiForContractName = (contractName: TokenContractName): AbiItem[] => { + return require(`../contracts/abis/${contractName}.json`); +}; + +export const getByteCodeForContractName = (contractName: TokenContractName): string => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require(`../contracts/bytecodes/${contractName}.json`).bytecode; +}; + +export const getContractFromName = (chainId: ChainId, contractName: TokenContractName, address?: string) => { + return getContractFromAbi(chainId, getAbiForContractName(contractName), address); +}; + +export { contractArtifacts, getChainId, getContract }; +export default { + getContractFromAbi, + getAbiForContractName, + getByteCodeForContractName, + getContractFromName, +}; diff --git a/apps/api/src/app/services/DiscordService.ts b/apps/api/src/app/services/DiscordService.ts new file mode 100644 index 000000000..5a1e270e7 --- /dev/null +++ b/apps/api/src/app/services/DiscordService.ts @@ -0,0 +1,57 @@ +import { client } from '../../discord'; +import { DiscordGuild, DiscordMessage, DiscordReaction } from '../models'; +import { DiscordUser } from '../models/DiscordUser'; +import { logger } from '../util/logger'; + +export default class DiscordService { + static async getGuild(poolId: string) { + const discordGuild = await DiscordGuild.findOne({ poolId }); + if (!discordGuild) return; + try { + // Might fail if bot is removed from the guild + return await client.guilds.fetch(discordGuild.guildId); + } catch (error) { + logger.error(error); + } + } + + static async getMember(guildId: string, userId: string) { + try { + // Might fail if bot is removed from the guild + return await client.guilds.fetch(guildId).then((guild) => guild.members.fetch(userId)); + } catch (error) { + logger.error(error); + } + } + + static async getRole(guildId: string, roleId: string) { + try { + return await client.guilds.fetch(guildId).then((guild) => guild.roles.fetch(roleId)); + } catch (error) { + logger.error(error); + } + } + + static async getUserMetrics(poolId: string, userId: string) { + const guild = await this.getGuild(poolId); + if (!guild) return; + + const member = await this.getMember(guild.id, userId); + if (!member) return; + + const profileImgUrl = member.user.displayAvatarURL({ forceStatic: true }); + const query = { guildId: guild.id, userId }; + + return await DiscordUser.create({ + userId, + guildId: guild.id, + profileImgUrl, + username: member.user.username, + publicMetrics: { + joinedAt: new Date(member.joinedTimestamp).toISOString(), + reactionCount: guild ? await DiscordReaction.countDocuments(query) : 0, + messageCount: guild ? await DiscordMessage.countDocuments(query) : 0, + }, + }); + } +} diff --git a/apps/api/src/app/services/ERC1155Service.ts b/apps/api/src/app/services/ERC1155Service.ts new file mode 100644 index 000000000..9d8c7fb4b --- /dev/null +++ b/apps/api/src/app/services/ERC1155Service.ts @@ -0,0 +1,351 @@ +import { keccak256, toUtf8Bytes } from 'ethers/lib/utils'; +import { TransactionReceipt } from 'web3-core'; +import { ChainId, TransactionState, ERC1155TokenState } from '@thxnetwork/common/enums'; +import { + getAbiForContractName, + getByteCodeForContractName, + getContractFromName, +} from '@thxnetwork/api/services/ContractService'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { paginatedResults } from '@thxnetwork/api/util/pagination'; +import { assertEvent, ExpectedEventNotFound, findEvent, parseLogs } from '@thxnetwork/api/util/events'; +import { API_URL, VERSION } from '../config/secrets'; +import TransactionService from './TransactionService'; +import PoolService from './PoolService'; +import IPFSService from './IPFSService'; +import SafeService from './SafeService'; +import { + Transaction, + ERC1155Document, + ERC1155, + PoolDocument, + RewardNFT, + ERC1155MetadataDocument, + ERC1155Metadata, + WalletDocument, + ERC1155TokenDocument, + ERC1155Token, +} from '@thxnetwork/api/models'; + +const contractName = 'THX_ERC1155'; + +async function deploy(data: TERC1155, forceSync = true): Promise { + const { defaultAccount } = getProvider(data.chainId); + const contract = getContractFromName(data.chainId, contractName); + const bytecode = getByteCodeForContractName(contractName); + const erc1155 = await ERC1155.create(data); + const baseURL = getBaseURL(erc1155); + const fn = contract.deploy({ + data: bytecode, + arguments: [baseURL, defaultAccount], + }); + + const txId = await TransactionService.sendAsync(null, fn, erc1155.chainId, forceSync, { + type: 'ERC1155DeployCallback', + args: { erc1155Id: String(erc1155._id) }, + }); + + return ERC1155.findByIdAndUpdate(erc1155._id, { transactions: [txId], baseURL }, { new: true }); +} + +async function deployCallback({ erc1155Id }: TERC1155DeployCallbackArgs, receipt: TransactionReceipt) { + const erc1155 = await ERC1155.findById(erc1155Id); + const contract = getContractFromName(erc1155.chainId, contractName); + const events = parseLogs(contract.options.jsonInterface, receipt.logs); + + if (!findEvent('OwnershipTransferred', events) && !findEvent('Transfer', events)) { + throw new ExpectedEventNotFound('Transfer or OwnershipTransferred'); + } + + await ERC1155.findByIdAndUpdate(erc1155Id, { address: receipt.contractAddress }); +} + +export async function queryDeployTransaction(erc1155: ERC1155Document): Promise { + if (!erc1155.address && erc1155.transactions[0]) { + const tx = await Transaction.findById(erc1155.transactions[0]); + const txResult = await TransactionService.queryTransactionStatusReceipt(tx); + if (txResult === TransactionState.Mined) { + erc1155 = await findById(erc1155._id); + } + } + + return erc1155; +} + +function getBaseURL(erc1155: ERC1155Document) { + return `${API_URL}/${VERSION}/metadata/erc1155/${String(erc1155._id)}/{id}`; +} + +const initialize = async (pool: PoolDocument, address: string) => { + const erc1155 = await findByQuery({ address, chainId: pool.chainId }); + await addMinter(erc1155, pool.safeAddress); +}; + +export async function findById(id: string): Promise { + const erc1155 = await ERC1155.findById(id); + if (!erc1155) return; + erc1155.logoImgUrl || erc1155.logoImgUrl || `https://api.dicebear.com/7.x/identicon/svg?seed=${erc1155.address}`; + return erc1155; +} + +export async function findBySub(sub: string): Promise { + const pools = await PoolService.getAllBySub(sub); + const nftRewards = await RewardNFT.find({ poolId: pools.map((p) => String(p._id)) }); + const erc1155Ids = nftRewards.map((c) => c.erc1155Id); + const erc1155s = await ERC1155.find({ sub }); + + return erc1155s.concat(await ERC1155.find({ _id: erc1155Ids })); +} + +export async function createMetadata(erc1155: ERC1155Document, attributes: any): Promise { + return ERC1155Metadata.create({ + erc1155: String(erc1155._id), + attributes, + }); +} + +export async function deleteMetadata(id: string) { + return ERC1155Metadata.findOneAndDelete({ _id: id }); +} + +export async function mint( + safe: WalletDocument, + erc1155: ERC1155Document, + wallet: WalletDocument, + metadata: ERC1155MetadataDocument, + amount: string, +): Promise { + const tokenUri = await IPFSService.getTokenURI(erc1155, String(metadata._id), String(metadata.tokenId)); + const erc1155token = await ERC1155Token.findOneAndUpdate( + { + erc1155Id: String(erc1155._id), + tokenId: metadata.tokenId, + sub: wallet.sub, + walletId: String(wallet._id), + }, + { + sub: wallet.sub, + tokenUri: erc1155.baseURL.replace('{id}', tokenUri), + recipient: wallet.address, + state: ERC1155TokenState.Pending, + erc1155Id: String(erc1155._id), + metadataId: String(metadata._id), + walletId: String(wallet._id), + tokenId: metadata.tokenId, + }, + { upsert: true, new: true }, + ); + + const tx = await TransactionService.sendSafeAsync( + safe, + erc1155.address, + erc1155.contract.methods.mint(wallet.address, metadata.tokenId, amount, '0x'), + { + type: 'erc1155TokenMintCallback', + args: { erc1155tokenId: String(erc1155token._id) }, + }, + ); + + return await ERC1155Token.findByIdAndUpdate( + erc1155token._id, + { transactions: [tx._id], state: ERC1155TokenState.Transferring }, + { new: true }, + ); +} + +export async function mintCallback(args: TERC1155TokenMintCallbackArgs, receipt: TransactionReceipt) { + const { erc1155tokenId } = args; + const abi = getAbiForContractName('THX_ERC1155'); + const events = parseLogs(abi, receipt.logs); + const event = assertEvent('TransferSingle', events); + + await ERC1155Token.findByIdAndUpdate(erc1155tokenId, { + state: ERC1155TokenState.Minted, + tokenId: event.args.id, + recipient: event.args.recipient, + }); +} + +export async function queryMintTransaction(erc1155Token: ERC1155TokenDocument): Promise { + if (erc1155Token.state === ERC1155TokenState.Pending && erc1155Token.transactions[0]) { + const tx = await Transaction.findById(erc1155Token.transactions[0]); + const txResult = await TransactionService.queryTransactionStatusReceipt(tx); + if (txResult === TransactionState.Mined) { + erc1155Token = await findTokenById(erc1155Token._id); + } + } + return erc1155Token; +} + +export async function transferFrom( + erc1155: ERC1155Document, + wallet: WalletDocument, + to: string, + erc1155Token: ERC1155TokenDocument, + amount: string, +): Promise { + const toWallet = await SafeService.findOne({ address: to, chainId: erc1155.chainId }); + const tx = await TransactionService.sendSafeAsync( + wallet, + erc1155.address, + erc1155.contract.methods.safeTransferFrom(wallet.address, to, erc1155Token.tokenId, amount, '0x'), + { + type: 'erc1155TransferFromCallback', + args: { + erc1155Id: String(erc1155._id), + erc1155TokenId: String(erc1155Token._id), + walletId: toWallet && toWallet.id, + }, + }, + ); + const metadata = await ERC1155Metadata.findById(erc1155Token.metadataId); + + await ERC1155Token.findOneAndUpdate( + { + erc1155Id: String(erc1155._id), + tokenId: metadata.tokenId, + sub: wallet.sub, + walletId: String(wallet._id), + }, + { + sub: wallet.sub, + tokenUri: erc1155.baseURL.replace('{id}', erc1155Token.tokenUri), + recipient: wallet.address, + state: ERC1155TokenState.Pending, + erc1155Id: String(erc1155._id), + metadataId: String(metadata._id), + walletId: String(wallet._id), + tokenId: metadata.tokenId, + }, + { upsert: true, new: true }, + ); + + return await ERC1155Token.findByIdAndUpdate( + erc1155Token._id, + { transactions: [String(tx._id)], state: ERC1155TokenState.Transferring }, + { new: true }, + ); +} + +export async function transferFromCallback(args: TERC1155TransferFromCallbackArgs, receipt: TransactionReceipt) { + const { erc1155TokenId, walletId } = args; + const abi = getAbiForContractName('THX_ERC1155'); + const events = parseLogs(abi, receipt.logs); + const event = assertEvent('TransferSingle', events); + const wallet = await SafeService.findById(walletId); + + await ERC1155Token.findByIdAndUpdate(erc1155TokenId, { + state: ERC1155TokenState.Transferred, + tokenId: event.args.id, + recipient: event.args.to, + sub: wallet && wallet.sub, + walletId: wallet && wallet.id, + }); +} + +async function isMinter(erc1155: ERC1155Document, address: string) { + return await erc1155.contract.methods.hasRole(keccak256(toUtf8Bytes('MINTER_ROLE')), address).call(); +} + +async function addMinter(erc1155: ERC1155Document, address: string) { + const receipt = await TransactionService.send( + erc1155.address, + erc1155.contract.methods.grantRole(keccak256(toUtf8Bytes('MINTER_ROLE')), address), + erc1155.chainId, + ); + + assertEvent('RoleGranted', parseLogs(erc1155.contract.options.jsonInterface, receipt.logs)); +} + +async function findMetadataByToken(token: TERC1155Token) { + return ERC1155Metadata.findById(token.metadataId); +} + +async function findTokenById(id: string): Promise { + return ERC1155Token.findById(id); +} + +async function findTokensByMetadataAndSub(metadataId: string, account: TAccount): Promise { + return ERC1155Token.find({ sub: account.sub, metadataId }); +} + +async function findTokensBySub(sub: string): Promise { + return ERC1155Token.find({ sub }); +} + +async function findTokensByWallet(wallet: WalletDocument): Promise { + return ERC1155Token.find({ walletId: wallet._id }); +} + +async function findMetadataById(id: string) { + return ERC1155Metadata.findById(id); +} + +async function findTokensByRecipient(recipient: string, erc1155Id: string): Promise { + const result = []; + for await (const token of ERC1155Token.find({ recipient, erc1155Id })) { + const metadata = await ERC1155Metadata.findById(token.metadataId); + result.push({ ...(token.toJSON() as TERC1155Token), metadata }); + } + return result; +} + +async function findTokensByMetadata(metadata: ERC1155MetadataDocument): Promise { + return ERC1155Token.find({ metadataId: String(metadata._id) }); +} + +async function findMetadataByNFT(erc1155Id: string, page = 1, limit = 10) { + const paginatedResult = await paginatedResults(ERC1155Metadata, page, limit, { erc1155Id }); + const results: TERC1155Metadata[] = []; + for (const metadata of paginatedResult.results) { + const tokens = (await this.findTokensByMetadata(metadata)).map((m: ERC1155MetadataDocument) => m.toJSON()); + results.push({ ...metadata.toJSON(), tokens }); + } + paginatedResult.results = results; + return paginatedResult; +} + +async function findByQuery(query: { poolAddress?: string; address?: string; chainId?: ChainId }) { + return ERC1155.findOne(query); +} + +export const update = (erc1155: ERC1155Document, updates: Partial) => { + return ERC1155.findByIdAndUpdate(erc1155._id, updates, { new: true }); +}; + +export const getOnChainERC1155Token = async (chainId: number, address: string) => { + const contract = getContractFromName(chainId, contractName, address); + const uri = await contract.methods.uri(1).call(); + + return { uri }; +}; + +export default { + deploy, + deployCallback, + findById, + createMetadata, + deleteMetadata, + mint, + mintCallback, + queryMintTransaction, + findBySub, + findTokenById, + findTokensByMetadataAndSub, + findTokensByMetadata, + findTokensBySub, + findMetadataById, + findMetadataByNFT, + findTokensByRecipient, + findByQuery, + addMinter, + isMinter, + update, + initialize, + transferFrom, + transferFromCallback, + queryDeployTransaction, + getOnChainERC1155Token, + findTokensByWallet, + findMetadataByToken, +}; diff --git a/apps/api/src/app/services/ERC20Service.ts b/apps/api/src/app/services/ERC20Service.ts new file mode 100644 index 000000000..f6d86cdf3 --- /dev/null +++ b/apps/api/src/app/services/ERC20Service.ts @@ -0,0 +1,339 @@ +import { toChecksumAddress } from 'web3-utils'; +import { assertEvent, ExpectedEventNotFound, findEvent, parseLogs } from '@thxnetwork/api/util/events'; +import { ChainId, ERC20Type, TransactionState } from '@thxnetwork/common/enums'; +import { getByteCodeForContractName, getContractFromName } from '@thxnetwork/api/services/ContractService'; +import { keccak256, toUtf8Bytes } from 'ethers/lib/utils'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { TransactionReceipt } from 'web3-core'; +import { contractNetworks, TokenContractName } from '@thxnetwork/api/contracts'; +import { + ERC20, + ERC20Document, + ERC20Token, + ERC20TokenDocument, + ERC20Transfer, + PoolDocument, + RewardCoin, + Transaction, + Wallet, + WalletDocument, +} from '@thxnetwork/api/models'; +import TransactionService from './TransactionService'; +import PoolService from './PoolService'; +import { fromWei } from 'web3-utils'; + +async function decorate(token: ERC20TokenDocument, wallet: WalletDocument) { + const erc20 = await getById(token.erc20Id); + if (!erc20 || erc20.chainId !== wallet.chainId) return; + + const walletBalanceInWei = await erc20.contract.methods.balanceOf(wallet.address).call(); + const walletBalance = fromWei(walletBalanceInWei, 'ether'); + + return Object.assign(token.toJSON() as TERC20Token, { + walletBalance, + erc20, + }); +} + +function getDeployArgs(erc20: ERC20Document, totalSupply?: string) { + const { defaultAccount } = getProvider(erc20.chainId); + + switch (erc20.type) { + case ERC20Type.Limited: { + return [erc20.name, erc20.symbol, defaultAccount, totalSupply]; + } + case ERC20Type.Unlimited: { + return [erc20.name, erc20.symbol, defaultAccount]; + } + } +} + +export async function findBySub(sub: string) { + const pools = await PoolService.getAllBySub(sub); + const coinRewards = await RewardCoin.find({ poolId: pools.map((p) => String(p._id)) }); + const erc20Ids = coinRewards.map((c) => c.erc20Id); + const erc20s = await ERC20.find({ sub }); + + return erc20s.concat(await ERC20.find({ _id: erc20Ids })); +} + +export const deploy = async (params: Partial, forceSync = true) => { + const erc20 = await ERC20.create({ + name: params.name, + symbol: params.symbol, + chainId: params.chainId, + type: params.type, + sub: params.sub, + logoImgUrl: params.logoImgUrl, + }); + + const contract = getContractFromName(params.chainId, erc20.contractName as TokenContractName); + const bytecode = getByteCodeForContractName(erc20.contractName as TokenContractName); + + const fn = contract.deploy({ + data: bytecode, + arguments: getDeployArgs(erc20, String(params.totalSupply)), + }); + + const txId = await TransactionService.sendAsync(null, fn, erc20.chainId, forceSync, { + type: 'Erc20DeployCallback', + args: { erc20Id: String(erc20._id) }, + }); + + return ERC20.findByIdAndUpdate(erc20._id, { transactions: [txId] }, { new: true }); +}; + +export async function deployCallback({ erc20Id }: TERC20DeployCallbackArgs, receipt: TransactionReceipt) { + const erc20 = await ERC20.findById(erc20Id); + const contract = getContractFromName(erc20.chainId, erc20.contractName as TokenContractName); + const events = parseLogs(contract.options.jsonInterface, receipt.logs); + + // Limited and unlimited tokes emit different events. Check if one of the two is emitted. + if (!findEvent('OwnershipTransferred', events) && !findEvent('Transfer', events)) { + throw new ExpectedEventNotFound('Transfer or OwnershipTransferred'); + } + + await ERC20.findByIdAndUpdate(erc20Id, { + address: receipt.contractAddress, + }); +} + +export async function queryDeployTransaction(erc20: ERC20Document): Promise { + if (!erc20.address && erc20.transactions[0]) { + const tx = await Transaction.findById(erc20.transactions[0]); + const txResult = await TransactionService.queryTransactionStatusReceipt(tx); + if (txResult === TransactionState.Mined) { + erc20 = await getById(erc20._id); + } + } + + return erc20; +} + +const initialize = async (pool: PoolDocument, erc20: ERC20Document) => { + if (erc20 && erc20.type === ERC20Type.Unlimited) { + await addMinter(erc20, pool.safeAddress); + } +}; + +const addMinter = async (erc20: ERC20Document, address: string) => { + const receipt = await TransactionService.send( + erc20.address, + erc20.contract.methods.grantRole(keccak256(toUtf8Bytes('MINTER_ROLE')), address), + erc20.chainId, + ); + + assertEvent('RoleGranted', parseLogs(erc20.contract.options.jsonInterface, receipt.logs)); +}; + +const addToken = async (wallet: WalletDocument, erc20: ERC20Document) => { + const query = { sub: wallet.sub, walletId: wallet.id, erc20Id: erc20._id }; + if (!(await ERC20Token.exists(query))) { + await createERC20Token(erc20, wallet); + } +}; + +export const getAll = (sub: string) => { + return ERC20.find({ sub }); +}; + +export const getTokensForSub = (sub: string) => { + return ERC20Token.find({ sub }); +}; + +export const getTokensForWallet = async (wallet: WalletDocument) => { + const tokens = await ERC20Token.find({ walletId: wallet.id }); + + const result = []; + for (const token of tokens) { + try { + const decorated = await decorate(token, wallet); + result.push(decorated); + } catch (error) { + console.log(error); + } + } + + const defaultTokens = (await findDefaultTokens(wallet)).filter(({ walletBalance }) => walletBalance > 0); + + return result.concat(defaultTokens); +}; + +export const getById = async (id: string) => { + const erc20 = await ERC20.findById(id); + if (!erc20) return; + + erc20.logoImgUrl = erc20.logoImgUrl || `https://api.dicebear.com/7.x/identicon/svg?seed=${erc20.address}`; + return erc20; +}; + +export const getTokenById = (id: string) => { + return ERC20Token.findById(id); +}; + +export const findBy = (query: { address: string; chainId: ChainId; sub?: string }) => { + return ERC20.findOne(query); +}; + +export const addTokenForWallet = async (erc20: ERC20Document, wallet: WalletDocument) => { + const hasToken = await ERC20Token.exists({ + sub: wallet.sub, + walletId: wallet.id, + erc20Id: erc20.id, + }); + + if (!hasToken) { + await createERC20Token(erc20, wallet); + } +}; + +export const importToken = async (chainId: number, address: string, sub: string, logoImgUrl: string) => { + const contract = getContractFromName(chainId, 'LimitedSupplyToken', address); + const [name, symbol] = await Promise.all([contract.methods.name().call(), contract.methods.symbol().call()]); + const erc20 = await ERC20.create({ + name, + symbol, + address: toChecksumAddress(address), + chainId, + type: ERC20Type.Unknown, + sub, + logoImgUrl, + }); + + const wallets = await Wallet.find({ sub }); + for (const wallet of wallets) { + await addTokenForWallet(erc20, wallet); + } + + return erc20; +}; + +export const update = (erc20: ERC20Document, updates: Partial) => { + return ERC20.findByIdAndUpdate(erc20._id, updates, { new: true }); +}; + +export const approve = async (erc20: ERC20Document, wallet: WalletDocument, amountInWei: string) => { + return await TransactionService.sendSafeAsync( + wallet, + erc20.address, + erc20.contract.methods.approve(wallet.address, amountInWei), + ); +}; + +export const transferFrom = async (erc20: ERC20Document, wallet: WalletDocument, to: string, amountInWei: string) => { + const erc20Transfer = await ERC20Transfer.create({ + erc20Id: erc20._id, + from: wallet.address, + to, + amount: amountInWei, + chainId: wallet.chainId, + sub: wallet.sub, + }); + + // Check if an erc20Token exists for a known receiving wallet and create one if not + const toWallet = await Wallet.findOne({ chainId: wallet.chainId, address: toChecksumAddress(to) }); + if (toWallet && !(await ERC20Token.exists({ walletId: toWallet._id, erc20Id: erc20._id }))) { + await createERC20Token(erc20, toWallet); + } + + const tx = await TransactionService.sendSafeAsync( + wallet, + erc20.address, + erc20.contract.methods.transfer(to, amountInWei), + { type: 'transferFromCallBack', args: { erc20Id: String(erc20._id) } }, + ); + + await erc20Transfer.updateOne({ transactionId: String(tx._id) }); + + return tx; +}; + +export const transferFromCallBack = async (args: TERC20TransferFromCallBackArgs, receipt: TransactionReceipt) => { + const erc20 = await ERC20.findById(args.erc20Id); + const events = parseLogs(erc20.contract.options.jsonInterface, receipt.logs); + + assertEvent('ERC20ProxyTransferFrom', events); +}; + +async function isMinter(erc20: ERC20Document, address: string) { + return await erc20.contract.methods.hasRole(keccak256(toUtf8Bytes('MINTER_ROLE')), address).call(); +} + +async function createERC20Token(erc20: ERC20Document, wallet: TWallet) { + await ERC20Token.create({ + sub: wallet.sub, + walletId: String(wallet._id), + erc20Id: String(erc20._id), + }); +} + +async function findDefaultTokens(wallet: WalletDocument) { + const defaultContracts = [ + { + type: ERC20Type.Unknown, + name: '20USDC-80THX', + symbol: '20USDC-80THX', + decimals: 18, + chainId: wallet.chainId, + address: contractNetworks[wallet.chainId].BPT, + logoImgUrl: 'https://assets.coingecko.com/coins/images/21323/standard/logo-thx-resized-200-200.png', + }, + { + type: ERC20Type.Unknown, + name: '20USDC-80THX (staked)', + symbol: '20USDC-80THX-gauge', + decimals: 18, + chainId: wallet.chainId, + address: contractNetworks[wallet.chainId].BPTGauge, + logoImgUrl: 'https://assets.coingecko.com/coins/images/21323/standard/logo-thx-resized-200-200.png', + }, + { + type: ERC20Type.Unknown, + name: 'Voting Escrow 20USDC-80THX-gauge', + symbol: 'veTHX', + decimals: 18, + chainId: wallet.chainId, + address: contractNetworks[wallet.chainId].VotingEscrow, + logoImgUrl: 'https://assets.coingecko.com/coins/images/21323/standard/logo-thx-resized-200-200.png', + }, + ]; + + const promises = defaultContracts.map(async (erc20) => { + const contract = getContractFromName(erc20.chainId, 'LimitedSupplyToken', erc20.address); + const walletBalanceInWei = await contract.methods.balanceOf(wallet.address).call(); + const walletBalance = Number(fromWei(walletBalanceInWei)); + return { + sub: wallet.sub, + erc20Id: '', + walletId: wallet.id, + walletBalance, + erc20, + }; + }); + + return await Promise.all(promises); +} + +export default { + findDefaultTokens, + decorate, + findBySub, + createERC20Token, + deploy, + getAll, + findBy, + getById, + addToken, + addMinter, + isMinter, + importToken, + getTokensForSub, + getTokenById, + update, + initialize, + queryDeployTransaction, + transferFrom, + transferFromCallBack, + getTokensForWallet, + approve, +}; diff --git a/apps/api/src/app/services/ERC721Service.ts b/apps/api/src/app/services/ERC721Service.ts new file mode 100644 index 000000000..399a714b8 --- /dev/null +++ b/apps/api/src/app/services/ERC721Service.ts @@ -0,0 +1,289 @@ +import { keccak256, toUtf8Bytes } from 'ethers/lib/utils'; +import { TransactionReceipt } from 'web3-core'; +import { getByteCodeForContractName, getContractFromName } from '@thxnetwork/api/services/ContractService'; +import { ERC721, ERC721Document } from '@thxnetwork/api/models/ERC721'; +import { ERC721Metadata, ERC721MetadataDocument } from '@thxnetwork/api/models/ERC721Metadata'; +import { ERC721Token, ERC721TokenDocument } from '@thxnetwork/api/models/ERC721Token'; +import { Transaction } from '@thxnetwork/api/models/Transaction'; +import { ERC721TokenState, TransactionState } from '@thxnetwork/common/enums'; +import { assertEvent, ExpectedEventNotFound, findEvent, parseLogs } from '@thxnetwork/api/util/events'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { paginatedResults } from '@thxnetwork/api/util/pagination'; +import { WalletDocument } from '../models/Wallet'; +import { RewardNFT } from '../models/RewardNFT'; +import PoolService from './PoolService'; +import TransactionService from './TransactionService'; +import IPFSService from './IPFSService'; +import WalletService from './WalletService'; + +const contractName = 'NonFungibleToken'; + +async function deploy(data: TERC721, forceSync = true): Promise { + const { defaultAccount } = getProvider(data.chainId); + const contract = getContractFromName(data.chainId, contractName); + const bytecode = getByteCodeForContractName(contractName); + const erc721 = await ERC721.create(data); + const fn = contract.deploy({ + data: bytecode, + arguments: [erc721.name, erc721.symbol, erc721.baseURL, defaultAccount], + }); + const txId = await TransactionService.sendAsync(null, fn, erc721.chainId, forceSync, { + type: 'Erc721DeployCallback', + args: { erc721Id: String(erc721._id) }, + }); + + return ERC721.findByIdAndUpdate(erc721._id, { transactions: [txId] }, { new: true }); +} + +async function deployCallback({ erc721Id }: TERC721DeployCallbackArgs, receipt: TransactionReceipt) { + const erc721 = await ERC721.findById(erc721Id); + const contract = getContractFromName(erc721.chainId, contractName); + const events = parseLogs(contract.options.jsonInterface, receipt.logs); + + if (!findEvent('OwnershipTransferred', events) && !findEvent('Transfer', events)) { + throw new ExpectedEventNotFound('Transfer or OwnershipTransferred'); + } + + await ERC721.findByIdAndUpdate(erc721Id, { address: receipt.contractAddress }); +} + +export async function queryDeployTransaction(erc721: ERC721Document): Promise { + if (!erc721.address && erc721.transactions[0]) { + const tx = await Transaction.findById(erc721.transactions[0]); + const txResult = await TransactionService.queryTransactionStatusReceipt(tx); + if (txResult === TransactionState.Mined) { + erc721 = await findById(erc721._id); + } + } + + return erc721; +} + +export async function findById(id: string): Promise { + const erc721 = await ERC721.findById(id); + if (!erc721) return; + erc721.logoImgUrl = erc721.logoImgUrl || `https://api.dicebear.com/7.x/identicon/svg?seed=${erc721.address}`; + return erc721; +} + +export async function findBySub(sub: string): Promise { + const pools = await PoolService.getAllBySub(sub); + const nftRewards = await RewardNFT.find({ poolId: pools.map((p) => String(p._id)) }); + const erc721Ids = nftRewards.map((c) => c.erc721Id); + const erc721s = await ERC721.find({ sub }); + + return erc721s.concat(await ERC721.find({ _id: erc721Ids })); +} + +export async function deleteMetadata(id: string) { + return ERC721Metadata.findOneAndDelete({ _id: id }); +} + +export async function mint( + safe: WalletDocument, + erc721: ERC721Document, + wallet: WalletDocument, + metadata: ERC721MetadataDocument, +): Promise { + const tokenUri = await IPFSService.getTokenURI(erc721, String(metadata._id)); + const erc721token = await ERC721Token.create({ + sub: wallet.sub, + tokenUri: erc721.baseURL + tokenUri, + recipient: wallet.address, + state: ERC721TokenState.Pending, + erc721Id: String(erc721._id), + metadataId: String(metadata._id), + walletId: wallet._id, + }); + + const tx = await TransactionService.sendSafeAsync( + safe, + erc721.address, + erc721.contract.methods.mint(wallet.address, tokenUri), + { + type: 'erc721TokenMintCallback', + args: { erc721tokenId: String(erc721token._id) }, + }, + ); + + return await ERC721Token.findByIdAndUpdate( + erc721token._id, + { transactions: [String(tx._id)], state: ERC721TokenState.Transferring }, + { new: true }, + ); +} + +export async function mintCallback(args: TERC721TokenMintCallbackArgs, receipt: TransactionReceipt) { + const token = await ERC721Token.findById(args.erc721tokenId); + const { contract } = await ERC721.findById(token.erc721Id); + const events = parseLogs(contract.options.jsonInterface, receipt.logs); + const event = assertEvent('Transfer', events); + + await token.updateOne({ + state: ERC721TokenState.Minted, + tokenId: Number(event.args.tokenId), + recipient: event.args.to, + }); +} + +export async function queryMintTransaction(erc721Token: ERC721TokenDocument): Promise { + if (erc721Token.state === ERC721TokenState.Pending && erc721Token.transactions[0]) { + const tx = await Transaction.findById(erc721Token.transactions[0]); + const txResult = await TransactionService.queryTransactionStatusReceipt(tx); + if (txResult === TransactionState.Mined) { + erc721Token = await ERC721Token.findById(erc721Token._id); + } + } + + return erc721Token; +} + +export function parseAttributes(entry: ERC721MetadataDocument) { + return { + name: entry.name, + description: entry.description, + image: entry.image, + external_url: entry.externalUrl, + }; +} + +async function isMinter(erc721: ERC721Document, address: string) { + return await erc721.contract.methods.hasRole(keccak256(toUtf8Bytes('MINTER_ROLE')), address).call(); +} + +async function addMinter(erc721: ERC721Document, address: string) { + const receipt = await TransactionService.send( + erc721.address, + erc721.contract.methods.grantRole(keccak256(toUtf8Bytes('MINTER_ROLE')), address), + erc721.chainId, + ); + + assertEvent('RoleGranted', parseLogs(erc721.contract.options.jsonInterface, receipt.logs)); +} + +async function findTokensByRecipient(recipient: string, erc721Id: string): Promise { + const result = []; + for await (const token of ERC721Token.find({ recipient, erc721Id })) { + const metadata = await ERC721Metadata.findById(token.metadataId); + result.push({ ...(token.toJSON() as TERC721Token), metadata }); + } + return result; +} + +async function findMetadataByToken(token: TERC721Token) { + return ERC721Metadata.findById(token.metadataId); +} + +async function findTokenById(id: string) { + return await ERC721Token.findById(id); +} + +async function findMetadataById(id: string) { + return await ERC721Metadata.findById(id); +} + +async function findMetadataByNFT(erc721Id: string, page = 1, limit = 10) { + const paginatedResult = await paginatedResults(ERC721Metadata, page, limit, { erc721Id }); + const results: TERC721Metadata[] = []; + for (const metadata of paginatedResult.results) { + const tokens = await ERC721Token.find({ erc721Id, metadataId: metadata._id }); + results.push({ ...metadata.toJSON(), tokens }); + } + paginatedResult.results = results; + return paginatedResult; +} + +export const getOnChainERC721Token = async (chainId: number, address: string) => { + const contract = getContractFromName(chainId, 'NonFungibleToken', address); + + const [name, symbol, totalSupply] = await Promise.all([ + contract.methods.name().call(), + contract.methods.symbol().call(), + contract.methods.totalSupply().call(), + ]); + + return { name, symbol, totalSupply }; +}; + +export async function transferFrom( + erc721: ERC721Document, + wallet: WalletDocument, + to: string, + erc721Token: ERC721TokenDocument, +): Promise { + const toWallet = await WalletService.findOne({ address: to, chainId: erc721.chainId }); + const tx = await TransactionService.sendSafeAsync( + wallet, + erc721.address, + erc721.contract.methods.transferFrom(wallet.address, to, erc721Token.tokenId), + { + type: 'erc721nTransferFromCallback', + args: { + erc721Id: String(erc721._id), + erc721TokenId: String(erc721Token._id), + walletId: toWallet && toWallet._id, + }, + }, + ); + return await ERC721Token.findByIdAndUpdate( + erc721Token._id, + { + transactions: [String(tx._id)], + state: ERC721TokenState.Transferring, + }, + { new: true }, + ); +} + +export async function transferFromCallback(args: TERC721TransferFromCallBackArgs, receipt: TransactionReceipt) { + const { erc721TokenId, walletId } = args; + const erc721Token = await ERC721Token.findById(erc721TokenId); + const erc721 = await ERC721.findById(erc721Token.erc721Id); + const events = parseLogs(erc721.contract.options.jsonInterface, receipt.logs); + const event = assertEvent('Transfer', events); + const wallet = await WalletService.findById(walletId); + + await erc721Token.updateOne({ + state: ERC721TokenState.Transferred, + tokenId: Number(event.args.tokenId), + recipient: event.args.to, + sub: wallet && wallet.sub, + walletId: wallet && String(wallet._id), + }); +} + +export async function queryTransferFromTransaction(erc721Token: ERC721TokenDocument): Promise { + if (erc721Token.state === ERC721TokenState.Transferring) { + const tx = await Transaction.findById(erc721Token.transactions[erc721Token.transactions.length - 1]); + const txResult = await TransactionService.queryTransactionStatusReceipt(tx); + if (txResult === TransactionState.Mined) { + erc721Token = await ERC721Token.findById(erc721Token._id); + } + } + + return erc721Token; +} + +export default { + deploy, + deployCallback, + findById, + deleteMetadata, + mint, + mintCallback, + queryMintTransaction, + findBySub, + findMetadataByNFT, + findTokensByRecipient, + addMinter, + isMinter, + parseAttributes, + queryDeployTransaction, + getOnChainERC721Token, + transferFrom, + transferFromCallback, + queryTransferFromTransaction, + findMetadataById, + findTokenById, + findMetadataByToken, +}; diff --git a/apps/api/src/app/services/GalachainService.ts b/apps/api/src/app/services/GalachainService.ts new file mode 100644 index 000000000..228f55a20 --- /dev/null +++ b/apps/api/src/app/services/GalachainService.ts @@ -0,0 +1,205 @@ +import axios from 'axios'; +import { instanceToPlain, plainToInstance } from 'class-transformer'; +import { BigNumber } from 'bignumber.js'; +import { logger } from '../util/logger'; +import { BadRequestError } from '../util/errors'; +import { + ChainCallDTO, + TokenInstance, + TokenClassKey, + TokenInstanceKey, + createValidDTO, + CreateTokenClassDto, + GalaChainResponse, + GetMyProfileDto, + MintTokenDto, + GrantAllowanceDto, + AllowanceType, + FetchBalancesDto, + TransferTokenDto, + RegisterUserDto, +} from '@gala-chain/api'; +import { GalachainRole, getClient } from '../util/galachain'; +import { Wallet } from 'ethers'; +import { NODE_ENV } from '../config/secrets'; + +const GALACHAIN_URL = 'https://gateway.stage.galachain.com/api'; +const identityKey = (address: string) => `eth|${address.replace(/^0x/, '')}`; + +export default class GalachainService { + static evaluateTransaction( + methodName: string, + contract: TGalachainContract, + dto: ChainCallDTO, + privateKey: string, + ) { + const methodMap = { + development: this.evaluateTransactionLocal.bind(this), + production: this.submitTransactonREST.bind(this), + }; + return methodMap[NODE_ENV](methodName, contract, dto, privateKey); + } + + static submitTransaction(methodName: string, contract: TGalachainContract, dto: ChainCallDTO, privateKey: string) { + const methodMap = { + development: this.submitTransactionLocal.bind(this), + production: this.submitTransactonREST.bind(this), + }; + return methodMap[NODE_ENV](methodName, contract, dto, privateKey); + } + + static async evaluateTransactionLocal( + methodName: string, + contract: TGalachainContract, + dto: ChainCallDTO, + privateKey: string, + ) { + const client = getClient(GalachainRole.Curator); // TODO Make this dynamic + const response = await client.forContract(contract).evaluateTransaction(methodName, dto.signed(privateKey)); + + if (GalaChainResponse.isError(response)) { + throw new Error(`${response.Message} (${response.ErrorKey})`); + } else { + return response.Data; + } + } + + static async submitTransactionLocal( + methodName: string, + contract: TGalachainContract, + dto: ChainCallDTO, + privateKey: string, + ) { + const client = getClient(GalachainRole.Curator); // TODO Make this dynamic + const response = await client.forContract(contract).submitTransaction(methodName, dto.signed(privateKey)); + + if (GalaChainResponse.isError(response)) { + throw new BadRequestError(`${response.Message} (${response.ErrorKey})`); + } else { + return response.Data; + } + } + + static async submitTransactonREST( + methodName: string, + contract: TGalachainContract, + dto: ChainCallDTO, + privateKey: string, + ) { + const signedDto = dto.signed(privateKey); + const url = new URL(GALACHAIN_URL); + url.pathname = `${url.pathname}/${contract.channelName}/${contract.chaincodeName}-${contract.contractName}/${methodName}`; + + try { + const res = await axios({ + method: 'POST', + url: url.toString(), + headers: {}, + data: instanceToPlain(signedDto), + }); + return res.data; + } catch (error) { + logger.error(error.response.data); + throw new BadRequestError(error.response.data.message); + } + } + + static getProfile(contract: TGalachainContract, privateKey: string) { + const dto = new GetMyProfileDto().signed(privateKey, false); + return this.evaluateTransaction('GetMyProfile', contract, dto, privateKey); + } + + static registerUser(contract: TGalachainContract, publicKey: string, privateKey: string) { + const dto = new RegisterUserDto(); + dto.publicKey = publicKey; + dto.sign(privateKey, false); + + return this.submitTransaction('RegisterEthUser', contract, dto, privateKey); + } + + static async balanceOf(contract: TGalachainContract, tokenClassKey: TGalachainToken, privateKey: string) { + const tokenClass = plainToInstance(TokenInstanceKey, tokenClassKey); + const owner = new Wallet(privateKey).address; + const dto = await createValidDTO(FetchBalancesDto, { + owner: identityKey(owner), + ...tokenClass, + }); + return this.evaluateTransaction('FetchBalances', contract, dto, privateKey); + } + + static async create( + contract: TGalachainContract, + tokenInfo: { + image: string; + name: string; + description: string; + symbol: string; + decimals: number; + maxSupply: any; + }, + tokenClassKey: TGalachainToken, + privateKey: string, + ) { + const tokenClass = plainToInstance(TokenClassKey, tokenClassKey); + const dto = await createValidDTO(CreateTokenClassDto, { + tokenClass, + ...tokenInfo, + }); + + return this.submitTransaction('CreateTokenClass', contract, dto, privateKey); + } + + static async mint( + contract: TGalachainContract, + tokenClassKey: TGalachainToken, + to: string, + amount: number, + privateKey: string, + ) { + const tokenClass = plainToInstance(TokenClassKey, tokenClassKey); + const dto = await createValidDTO(MintTokenDto, { + owner: identityKey(to), + tokenClass, + quantity: new BigNumber(amount) as any, + }); + + return this.submitTransaction('MintToken', contract, dto, privateKey); + } + + static async approve( + contract: TGalachainContract, + tokenClassKey: TGalachainToken, + spender: string, + amount: number, + allowanceType: AllowanceType, + privateKey: string, + ) { + const dto = await createValidDTO(GrantAllowanceDto, { + tokenInstance: TokenInstanceKey.nftKey(tokenClassKey, TokenInstance.FUNGIBLE_TOKEN_INSTANCE).toQueryKey(), + allowanceType, + quantities: [{ user: identityKey(spender), quantity: new BigNumber(amount) as any }], + uses: new BigNumber(amount) as any, + }); + + return this.submitTransaction('GrantAllowance', contract, dto, privateKey); + } + + static async transfer( + contract: TGalachainContract, + tokenClassKey: TGalachainToken, + to: string, + amount: number, + instance: BigNumber, + privateKey: string, + ) { + const tokenInstance = plainToInstance(TokenInstanceKey, { ...tokenClassKey, instance }); + const dto = await createValidDTO(TransferTokenDto, { + from: identityKey(new Wallet(privateKey).address), + to: identityKey(to), + tokenInstance, + quantity: new BigNumber(amount) as any, + }); + + return this.submitTransaction('TransferToken', contract, dto, privateKey); + } +} diff --git a/apps/api/src/app/services/GitcoinService.ts b/apps/api/src/app/services/GitcoinService.ts new file mode 100644 index 000000000..721975b27 --- /dev/null +++ b/apps/api/src/app/services/GitcoinService.ts @@ -0,0 +1,32 @@ +import axios from 'axios'; +import { GITCOIN_API_KEY } from '../config/secrets'; +import { logger } from '../util/logger'; +export default class GitcoinService { + static async submitPassport(scorerId: number, address: string) { + await axios({ + method: 'POST', + url: 'https://api.scorer.gitcoin.co/registry/submit-passport', + headers: { 'X-API-KEY': GITCOIN_API_KEY }, + data: { + address, + scorer_id: scorerId, + }, + }); + } + + static async getScoreUniqueHumanity(scorerId: number, address: string) { + try { + await this.submitPassport(scorerId, address); + + const { data } = await axios({ + method: 'GET', + url: `https://api.scorer.gitcoin.co/registry/score/${scorerId}/${address}`, + headers: { 'X-API-KEY': GITCOIN_API_KEY }, + }); + return { score: data.score === '0E-9' ? 0 : data.score }; + } catch (error) { + logger.error(error.message); + return { error: `Could not get a score for ${address}.` }; + } + } +} diff --git a/apps/api/src/app/services/IPFSService.ts b/apps/api/src/app/services/IPFSService.ts new file mode 100644 index 000000000..be61d081d --- /dev/null +++ b/apps/api/src/app/services/IPFSService.ts @@ -0,0 +1,44 @@ +import { API_URL, NODE_ENV } from '../config/secrets'; +import { NFTVariant } from '@thxnetwork/common/enums'; +import { ERC721Document, ERC1155Document } from '@thxnetwork/api/models'; +import axios from 'axios'; +import pinataSDK from '@pinata/sdk'; +import https from 'https'; + +const pinata = new pinataSDK({ pinataJWTKey: process.env.PINATA_API_JWT }); + +if (NODE_ENV !== 'production') { + const httpsAgent = new https.Agent({ + rejectUnauthorized: false, + }); + axios.defaults.httpsAgent = httpsAgent; +} + +export async function addUrlSource(url: string) { + const response = await axios.get(url, { responseType: 'stream' }); + const urlParts = url.split('/'); + const name = urlParts[urlParts.length - 1]; + const { IpfsHash } = await pinata.pinFileToIPFS(response.data, { + pinataMetadata: { name }, + pinataOptions: { cidVersion: 0 }, + }); + return IpfsHash; +} + +async function getTokenURI(nft: ERC721Document | ERC1155Document, metadataId: string, tokenId?: string) { + const tokenUri = { + [NFTVariant.ERC721]: metadataId, + [NFTVariant.ERC1155]: tokenId, + }; + // During tests we can not grab data from an url due to TLS issues, hence we return the internally used tokenUri + if (NODE_ENV === 'test') return tokenUri[nft.variant]; + + const metadataUrl = { + [NFTVariant.ERC721]: `${API_URL}/v1/metadata/${metadataId}`, + [NFTVariant.ERC1155]: `${API_URL}/v1/metadata/erc1155/${nft._id}/${tokenId}`, + }; + + return await addUrlSource(metadataUrl[nft.variant]); +} + +export default { addUrlSource, getTokenURI }; diff --git a/apps/api/src/app/services/IdentityService.ts b/apps/api/src/app/services/IdentityService.ts new file mode 100644 index 000000000..95c22ee79 --- /dev/null +++ b/apps/api/src/app/services/IdentityService.ts @@ -0,0 +1,33 @@ +import { WalletVariant } from '@thxnetwork/common/enums'; +import { Wallet, Identity, PoolDocument } from '@thxnetwork/api/models'; +import { uuidV1 } from '../util/uuid'; + +export default class IdentityService { + static getUUID(pool: PoolDocument, salt: string) { + const poolId = String(pool._id); + return uuidV1(`${poolId}${salt}`); + } + + // Derive uuid v1 from poolId + salt. Using uuid v1 format so we can + // validate the input using express-validator + static getIdentityForSalt(pool: PoolDocument, salt: string) { + const uuid = this.getUUID(pool, salt); + return Identity.findOneAndUpdate( + { poolId: pool._id, uuid }, + { poolId: pool._id, uuid }, + { new: true, upsert: true }, + ); + } + + static async forceConnect(pool: PoolDocument, account: TAccount) { + // Search for WalletConnect wallets for this sub + const wallets = await Wallet.find({ sub: account.sub, variant: WalletVariant.WalletConnect }); + if (!wallets.length) return; + + // Create a list of uuids for these wallets + const uuids = wallets.map((wallet) => this.getUUID(pool, wallet.address)); + + // Find any identity for these uuids and update + await Identity.findOneAndUpdate({ uuid: { $in: uuids } }, { sub: account.sub }); + } +} diff --git a/apps/api/src/app/services/ImageService.ts b/apps/api/src/app/services/ImageService.ts new file mode 100644 index 000000000..137330c27 --- /dev/null +++ b/apps/api/src/app/services/ImageService.ts @@ -0,0 +1,29 @@ +import short from 'short-uuid'; +import { AWS_S3_PUBLIC_BUCKET_NAME, AWS_S3_PUBLIC_BUCKET_REGION } from '@thxnetwork/api/config/secrets'; +import { s3Client } from '@thxnetwork/api/util/s3'; +import { PutObjectCommand } from '@aws-sdk/client-s3'; + +async function upload(file: Express.Multer.File) { + const [originalname, extension] = file.originalname.split('.'); + const filename = + originalname.toLowerCase().split(' ').join('-').split('.') + '-' + short.generate() + `.${extension}`; + const type = extension === 'svg' ? 'image/svg+xml' : 'image/*'; + return this.uploadToS3(file.buffer, filename, type); +} + +async function uploadToS3(fileBuffer: Buffer, filename, type) { + await s3Client.send( + new PutObjectCommand({ + Key: filename, + Bucket: AWS_S3_PUBLIC_BUCKET_NAME, + ACL: 'public-read', + Body: fileBuffer, + ContentType: type, + ContentDisposition: 'inline', + }), + ); + + return `https://${AWS_S3_PUBLIC_BUCKET_NAME}.s3.${AWS_S3_PUBLIC_BUCKET_REGION}.amazonaws.com/${filename}`; +} + +export default { upload, uploadToS3 }; diff --git a/apps/api/src/app/services/InvoiceService.ts b/apps/api/src/app/services/InvoiceService.ts new file mode 100644 index 000000000..7e1f78297 --- /dev/null +++ b/apps/api/src/app/services/InvoiceService.ts @@ -0,0 +1,141 @@ +import { planPricingMap } from '@thxnetwork/common/constants'; +import { + Pool, + Invoice, + QuestDailyEntry, + QuestInviteEntry, + QuestSocialEntry, + QuestCustomEntry, + QuestWeb3Entry, + QuestGitcoinEntry, +} from '../models'; +import AccountProxy from '../proxies/AccountProxy'; +import { logger } from '../util/logger'; +import { AccountPlanType } from '@thxnetwork/common/enums'; +import { startOfMonth, endOfMonth } from 'date-fns'; + +export default class InvoiceService { + /** + * Upsert invoices for the current month. Periodically (daily) invoked by the agenda job scheduler. + */ + static async upsertJob() { + const currentDate = new Date(); + // Define the start and end dates for the month range + const invoicePeriodstartDate = startOfMonth(currentDate); + const invoicePeriodEndDate = endOfMonth(currentDate); + + await this.upsertInvoices(invoicePeriodstartDate, invoicePeriodEndDate); + } + + /** + * Upsert invoices for a given period. Used independently for testing and backfills. + * @param invoicePeriodstartDate + * @param invoicePeriodEndDate + */ + static async upsertInvoices(invoicePeriodstartDate: Date, invoicePeriodEndDate: Date) { + // Determine the lookup stages for the quest entries in the pools pipeline + const questEntryModels = [ + QuestDailyEntry, + QuestInviteEntry, + QuestSocialEntry, + QuestCustomEntry, + QuestWeb3Entry, + QuestGitcoinEntry, + ]; + + // Get all relevant pools + const pools = await Pool.find({ 'settings.isPublished': true }); + const questEntriesByCampaign = await Promise.all( + pools.map(async (pool) => { + const uniqueEntriesByVariant = await Promise.all( + questEntryModels.map(async (model) => { + return await model + .countDocuments({ + poolId: pool.id, + createdAt: { $gte: invoicePeriodstartDate, $lte: invoicePeriodEndDate }, + }) + .distinct('sub'); + }), + ); + const flattenedArray = uniqueEntriesByVariant.flat(); + + return { poolId: pool.id, poolSub: pool.sub, mapCount: new Set(flattenedArray).size }; + }), + ); + + // Get the pool owner accounts to send the invoices + const subs = questEntriesByCampaign.map(({ poolSub }) => poolSub); + const accounts = await AccountProxy.find({ subs }); + + // Build operations array for the current month metrics + const operations = questEntriesByCampaign.map(({ poolId, poolSub, mapCount }) => { + try { + const account = accounts.find((a) => a.sub === poolSub); + // If the account can not be found, has no email or plan then notify admin. + // Continue with invoice generation for future reference + // @todo: notify admin + if (!account) { + logger.info(`Account ${account.sub} not found for invoicing.`); + } + if (!account.email) { + logger.info(`Account ${account.sub} has no email for invoicing.`); + } + if (![AccountPlanType.Lite, AccountPlanType.Premium].includes(account.plan)) { + logger.info(`Account ${account.sub} has no plan for invoicing.`); + } + + return { + updateOne: { + filter: { + poolId, + periodStartDate: invoicePeriodstartDate, + periodEndDate: invoicePeriodEndDate, + }, + update: { + $set: { + poolId, + periodStartDate: invoicePeriodstartDate, + periodEndDate: invoicePeriodEndDate, + mapCount, + mapLimit: planPricingMap[account.plan].subscriptionLimit, + ...this.createInvoiceDetails(account, mapCount), + }, + }, + upsert: true, + }, + }; + } catch (error) { + logger.error(error); + } + }); + + // Remove empty ops and bulk write the invoices + await Invoice.bulkWrite(operations.filter((op) => !!op)); + } + + /** + * Create invoice details for the given account and monthly active participant count + * @param account + * @param mapCount + * @returns invoice details used for upsert in db + */ + static createInvoiceDetails(account: TAccount, mapCount: number) { + const countAdditionalUnits = (mapCount: number, limit: number) => { + return Math.max(0, mapCount - limit); + }; + const plan = account.plan || AccountPlanType.Lite; + const { subscriptionLimit, costPerUnit, costSubscription } = planPricingMap[plan]; + + // Plan limit is subtracted from unit count as costs are included in subscription costs + const additionalUnitCount = countAdditionalUnits(mapCount, subscriptionLimit); + + return { + additionalUnitCount, + costPerUnit, + costSubscription, + costTotal: costSubscription + additionalUnitCount * costPerUnit, + currency: 'USDC', + plan: account.plan, + }; + } +} diff --git a/apps/api/src/app/services/LiquidityService.ts b/apps/api/src/app/services/LiquidityService.ts new file mode 100644 index 000000000..66e42857c --- /dev/null +++ b/apps/api/src/app/services/LiquidityService.ts @@ -0,0 +1,27 @@ +import { contractNetworks } from '@thxnetwork/api/contracts'; +import { WalletDocument } from '../models/Wallet'; +import { contractArtifacts } from './ContractService'; +import { getProvider } from '../util/network'; +import TransactionService from './TransactionService'; +import BalancerService from './BalancerService'; + +export default class LiquidityService { + static async create(wallet: WalletDocument, usdcAmountInWei: string, thxAmountInWei: string, slippage: string) { + const { to, data } = await BalancerService.buildJoin(wallet, usdcAmountInWei, thxAmountInWei, slippage); + return await TransactionService.proposeSafeAsync(wallet, to, data); + } + + static async stake(wallet: WalletDocument, amountInWei: string) { + const { web3 } = getProvider(wallet.chainId); + + // Deposit the BPT into the gauge + const bptGauge = new web3.eth.Contract( + contractArtifacts['BPTGauge'].abi, + contractNetworks[wallet.chainId].BPTGauge, + ); + const fn = bptGauge.methods.deposit(amountInWei); + + // Propose tx data to relayer and return safeTxHash to client to sign + return await TransactionService.sendSafeAsync(wallet, bptGauge.options.address, fn); + } +} diff --git a/apps/api/src/app/services/LockService.ts b/apps/api/src/app/services/LockService.ts new file mode 100644 index 000000000..951387b6f --- /dev/null +++ b/apps/api/src/app/services/LockService.ts @@ -0,0 +1,50 @@ +import { QuestSocialDocument } from '../models'; +import QuestService from './QuestService'; +import { serviceMap } from './interfaces/IQuestService'; + +async function getIsUnlocked(lock: TQuestLock, account: TAccount): Promise { + const ids: any = [{ sub: account.sub }]; + + // For these social quests we also search for existing entries by platformUserId + const quest = (await QuestService.findById(lock.variant, lock.questId)) as QuestSocialDocument; + if (quest.interaction) { + const platformUserId = QuestService.findUserIdForInteraction(account, quest.interaction); + if (platformUserId) ids.push({ platformUserId }); + } + + const Entry = serviceMap[lock.variant].models.entry; + const exists = await Entry.exists({ questId: lock.questId, $or: ids }); + + return !!exists; +} + +async function getIsLocked(locks: TQuestLock[], account: TAccount) { + if (!locks.length || !account) return false; + + // Check if there are entries for the remaining quests + const promises = locks.map((lock) => getIsUnlocked(lock, account)); + const results = await Promise.allSettled(promises); + const anyRejected = results.some((result) => result.status === 'rejected'); + if (anyRejected) return true; + + return results + .filter((result) => result.status === 'fulfilled') + .map((result: any & { value: boolean }) => result.value) + .includes(false); +} + +async function removeAllLocks(questId: string) { + for (const variant in Object.keys(serviceMap)) { + const Quest = serviceMap[variant].models.quest; + const lockedQuests = await Quest.find({ 'locks.questId': questId }); + + for (const lockedQuest of lockedQuests) { + const index = lockedQuest.locks.findIndex((lock: TQuestLock) => lock.questId === questId); + const locks = lockedQuest.locks.splice(index, 1); + + await lockedQuest.updateOne({ locks }); + } + } +} + +export default { getIsLocked, removeAllLocks }; diff --git a/apps/api/src/app/services/MailService.ts b/apps/api/src/app/services/MailService.ts new file mode 100644 index 000000000..0aafd427e --- /dev/null +++ b/apps/api/src/app/services/MailService.ts @@ -0,0 +1,33 @@ +import { + AUTH_URL, + NODE_ENV, + CYPRESS_EMAIL, + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, +} from '@thxnetwork/api/config/secrets'; +import path from 'path'; +import { assetsPath } from '../util/path'; +import ejs from 'ejs'; +import { sendMail } from '@thxnetwork/common/mail'; +import { logger } from '../util/logger'; + +const mailTemplatePath = path.join(assetsPath, 'views', 'email'); + +const send = async (to: string, subject: string, htmlContent: string, link = { src: '', text: '' }) => { + if (!to) return; + + const html = await ejs.renderFile( + path.join(mailTemplatePath, 'base-template.ejs'), + { link, subject, htmlContent, baseUrl: AUTH_URL }, + { async: true }, + ); + + if (!AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY || NODE_ENV === 'test' || CYPRESS_EMAIL === to) { + logger.debug({ message: 'Not sending e-mail', link }); + return; + } + + return sendMail(to, subject, html); +}; + +export default { send }; diff --git a/apps/api/src/app/services/NotificationService.ts b/apps/api/src/app/services/NotificationService.ts new file mode 100644 index 000000000..782794e62 --- /dev/null +++ b/apps/api/src/app/services/NotificationService.ts @@ -0,0 +1,142 @@ +import { QuestVariant } from '@thxnetwork/common/enums'; +import { PoolDocument } from '@thxnetwork/api/models'; +import { logger } from '../util/logger'; +import { sleep } from '../util'; +import { Notification, Widget, Participant } from '@thxnetwork/api/models'; +import { DiscordButtonVariant } from '../events/InteractionCreated'; +import { ButtonStyle } from 'discord.js'; +import { WIDGET_URL } from '../config/secrets'; +import { celebratoryWords } from '../util/dictionaries'; +import AccountProxy from '../proxies/AccountProxy'; +import MailService from './MailService'; +import PoolService from './PoolService'; +import BrandService from './BrandService'; +import DiscordDataProxy from '../proxies/DiscordDataProxy'; + +const MAIL_CHUNK_SIZE = 600; + +async function send( + pool: PoolDocument, + { subjectId, subject, message, link }: Partial & { link?: { src: string; text: string } }, +) { + const participants = await Participant.find({ poolId: pool._id, isSubscribed: true }); + const subs = participants.map((p) => p.sub); + const accounts = (await AccountProxy.find({ subs })).filter((a) => a.email); + + // Create chunks for bulk email sending to avoid hitting Sendgrit rate limits + for (let i = 0; i < subs.length; i += MAIL_CHUNK_SIZE) { + const chunk = subs.slice(i, i + MAIL_CHUNK_SIZE); + await Promise.all( + chunk.map(async (sub) => { + try { + // Make sure to not sent duplicate notifications + // for the same subjectId + const isNotifiedAlready = await Notification.exists({ sub, subjectId }); + if (isNotifiedAlready) return; + + const account = accounts.find((a) => a.sub === sub); + await MailService.send(account.email, subject, message, link); + + await Notification.create({ sub, poolId: pool._id, subjectId, subject, message }); + } catch (error) { + logger.error(error); + } + }), + ); + + // Sleep 60 seconds before sending the next chunk + await sleep(60); + } +} + +async function notify(variant: QuestVariant, quest: TQuest) { + const [pool, brand, widget] = await Promise.all([ + PoolService.getById(quest.poolId), + BrandService.get(quest.poolId), + Widget.findOne({ poolId: quest.poolId }), + ]); + + sendQuestPublishEmail(pool, variant, quest as TQuest, widget); + sendQuestPublishNotification(pool, variant, quest as TQuest, widget, brand); +} + +async function sendQuestPublishEmail(pool: PoolDocument, variant: QuestVariant, quest: TQuest, widget: TWidget) { + const { amount, amounts } = quest as any; + const subject = `🎁 New ${QuestVariant[variant]} Quest: Earn ${amount || amounts[0]} pts!"`; + const message = `

Earn ${amount || amounts[0]} points!🔔

+

Hi! ${pool.settings.title} just published a new ${QuestVariant[variant]} Quest. +

${quest.title}
${quest.description}.

`; + const src = WIDGET_URL + `/c/${pool.settings.slug}`; + + send(pool, { + subjectId: quest.uuid, + subject, + message, + link: { text: `Complete ${QuestVariant[variant]} Quest`, src }, + }); +} + +async function sendQuestPublishNotification( + pool: PoolDocument, + variant: QuestVariant, + quest: TQuest, + widget: TWidget, + brand?: TBrand, +) { + const theme = JSON.parse(widget.theme); + const { amount, amounts } = quest as any; + + const embed = { + title: quest.title, + description: quest.description, + author: { + name: pool.settings.title, + icon_url: brand ? brand.logoImgUrl : '', + url: widget.domain, + }, + image: { url: quest.image }, + color: parseInt(theme.elements.btnBg.color.replace(/^#/, ''), 16), + fields: [ + { + name: 'Points', + value: `${amount || amounts[0]}`, + inline: true, + }, + { + name: 'Type', + value: `${QuestVariant[quest.variant]}`, + inline: true, + }, + ], + }; + + await DiscordDataProxy.sendChannelMessage( + pool, + `Hi @everyone! We published a **${QuestVariant[variant]} Quest**.`, + [embed], + [ + { + customId: `${DiscordButtonVariant.QuestComplete}:${quest.variant}:${quest._id}`, + label: 'Complete Quest!', + style: ButtonStyle.Success, + }, + { label: 'More Info', style: ButtonStyle.Link, url: WIDGET_URL + `/c/${pool.settings.slug}` }, + ], + ); +} + +async function sendQuestEntryNotification(pool: PoolDocument, quest: TQuest, account: TAccount, amount: number) { + const index = Math.floor(Math.random() * celebratoryWords.length); + const discord = account.tokens && account.tokens.find((a) => a.kind === 'discord'); + const user = discord && discord.userId ? `<@${discord.userId}>` : `**${account.username}**`; + const button = { + customId: `${DiscordButtonVariant.QuestComplete}:${quest.variant}:${quest._id}`, + label: 'Complete Quest', + style: ButtonStyle.Primary, + }; + const content = `${celebratoryWords[index]} ${user} completed the **${quest.title}** quest and earned **${amount} points.**`; + + await DiscordDataProxy.sendChannelMessage(pool, content, [], [button]); +} + +export default { send, notify, sendQuestEntryNotification }; diff --git a/apps/api/src/app/services/ParticipantService.ts b/apps/api/src/app/services/ParticipantService.ts new file mode 100644 index 000000000..34973b370 --- /dev/null +++ b/apps/api/src/app/services/ParticipantService.ts @@ -0,0 +1,60 @@ +import { Document } from 'mongoose'; +import { DiscordReaction, Participant, TwitterUser } from '../models'; +import ReCaptchaService from '@thxnetwork/api/services/ReCaptchaService'; +import { AccessTokenKind } from '@thxnetwork/common/enums'; +import DiscordService from './DiscordService'; +import { DiscordUser } from '../models/DiscordUser'; + +export default class ParticipantService { + static async decorate( + data: Document & (TQuestEntry | TRewardPayment), + { accounts, participants }: { accounts: TAccount[]; participants: TParticipant[] }, + ) { + const account = accounts.find((a) => a.sub === data.sub); + const pointBalance = participants.find((p) => account.sub === String(p.sub)); + const tokens = await Promise.all( + account.tokens.map(async (token: TToken) => { + if (token.kind !== 'twitter') return token; + const user = await TwitterUser.findOne({ userId: token.userId }); + return { ...token, user }; + }), + ); + + return { + ...data.toJSON(), + account: { ...account, tokens }, + pointBalance: pointBalance ? pointBalance.balance : 0, + }; + } + + static async updateRiskScore( + account: TAccount, + poolId: string, + { token, recaptchaAction }: { token: string; recaptchaAction: string }, + ) { + // Get risk score from Google + const riskAnalysis = await ReCaptchaService.getRiskAnalysis({ + token, + recaptchaAction, + }); + + // Update the participant's risk score + return await Participant.findOneAndUpdate({ sub: account.sub, poolId }, { riskAnalysis }, { new: true }); + } + + static async findUser(token: TToken, { userId, guildId }: { userId: string; guildId?: string }) { + const userModelMap = { + [AccessTokenKind.Twitter]: () => TwitterUser.findOne({ userId }), + [AccessTokenKind.Discord]: () => DiscordUser.findOne({ userId, guildId }), + }; + + const user = userModelMap[token.kind] && (await userModelMap[token.kind]()); + + return { + kind: token.kind, + userId: token.userId, + metadata: token.metadata, + user, + } as unknown as TToken; + } +} diff --git a/apps/api/src/app/services/PaymentService.ts b/apps/api/src/app/services/PaymentService.ts new file mode 100644 index 000000000..7bd53caec --- /dev/null +++ b/apps/api/src/app/services/PaymentService.ts @@ -0,0 +1,112 @@ +import { contractArtifacts, contractNetworks } from '@thxnetwork/api/contracts'; +import { Pool, WalletDocument } from '../models'; +import { getProvider } from '../util/network'; +import { BigNumber } from 'ethers'; +import { differenceInSeconds, isBefore, subWeeks } from 'date-fns'; +import { getContract } from './ContractService'; +import { AccountPlanType, ChainId } from '@thxnetwork/common/enums'; +import { Payment } from '../models/Payment'; +import { planPricingMap } from '@thxnetwork/common/constants'; +import { parseUnits } from 'ethers/lib/utils'; +import TransactionService from './TransactionService'; +import SafeService from './SafeService'; +import BalancerService from './BalancerService'; + +const ONE_DAY = 60 * 60 * 24; + +export default class PaymentService { + static async deposit(safe: WalletDocument, sub: string, amountInWei: BigNumber) { + const { web3 } = getProvider(safe.chainId); + const addresses = contractNetworks[safe.chainId]; + const contract = new web3.eth.Contract( + contractArtifacts['THXPaymentSplitter'].abi, + addresses.THXPaymentSplitter, + ); + + // @dev Using default slippage value here as payments + const { minBPTOut } = await BalancerService.buildJoin(safe, amountInWei.toString(), '0', '50'); + const fn = contract.methods.deposit(safe.address, amountInWei, minBPTOut); + + await Payment.create({ poolId: safe.poolId, sub, amountInWei }); + + return await TransactionService.sendSafeAsync(safe, addresses.THXPaymentSplitter, fn); + } + + static async balanceOf(wallet: WalletDocument) { + // TODO Deploy Polygon PaymentSplitter before using this middleware + const { THXPaymentSplitter } = contractNetworks[wallet.chainId]; + if (!THXPaymentSplitter && wallet.chainId === ChainId.Polygon) { + return '0'; + } + + const splitter = getContract('THXPaymentSplitter', wallet.chainId, THXPaymentSplitter); + const balance = await splitter.balanceOf(wallet.address); + return balance.toString(); + } + + static async getRate(wallet: WalletDocument) { + const splitter = getContract( + 'THXPaymentSplitter', + wallet.chainId, + contractNetworks[wallet.chainId].THXPaymentSplitter, + ); + const rate = await splitter.rates(wallet.address); + return rate.toString(); + } + + static async setRate(safe: WalletDocument, plan: AccountPlanType) { + const { web3 } = getProvider(safe.chainId); + const addresses = contractNetworks[safe.chainId]; + const contract = new web3.eth.Contract( + contractArtifacts['THXPaymentSplitter'].abi, + addresses.THXPaymentSplitter, + ); + // Convert plan pricing to rate in wei per second + const pricing = planPricingMap[plan]; + // Using 6 decimals for USDC + const costInWeiPerThirtyDays = parseUnits(pricing.costSubscription.toString(), 6); + // Plan pricing is determined on a per 4 week basis + const rateInWeiPerSecond = costInWeiPerThirtyDays.div(4 * 7 * 24 * 60 * 60); + const fn = contract.methods.setRate(rateInWeiPerSecond); + + return await TransactionService.sendSafeAsync(safe, addresses.PaymentSplitter, fn); + } + + static async getTimeLeftInSeconds(safe: WalletDocument, pool: TPool) { + const now = new Date(); + const isTrial = isBefore(pool.trialEndsAt, now); + if (isTrial) return BigNumber.from(differenceInSeconds(pool.trialEndsAt, now)); + + // Devide balance by rate and calculate time left for the pool + const balanceInWei = await this.balanceOf(safe); + const rateInWeiPerSecond = await this.getRate(safe); + return BigNumber.from(balanceInWei).div(rateInWeiPerSecond); + } + + static async assertPaymentsJob() { + // Skip pools that have no trialEnd date (legacy) or are still in the first week of their trial + const pools = await Pool.find({ trialEndsAt: { $exists: true, $lt: subWeeks(new Date(), 1) } }); + for (const pool of pools) { + // Get campaing safe + const safe = await SafeService.findOneByPool(pool); + const timeLeftInSeconds = await this.getTimeLeftInSeconds(safe, pool); + + // Insufficient payments + if (timeLeftInSeconds.eq(0)) { + // Send a reminder to make payment and inform about campaign pause change + } + // 1 day before balance hitting zero send a reminder to make payment + else if (timeLeftInSeconds.lt(ONE_DAY)) { + // Send reminder to make payment + } + // 3 days before balance hitting zero send a reminder to make payment + else if (timeLeftInSeconds.lt(ONE_DAY * 3)) { + // Send reminder to make payment + } + // 1 week before balance hitting zero send a reminder to make payment + else if (timeLeftInSeconds.lt(ONE_DAY * 7)) { + // Send reminder to make payment + } + } + } +} diff --git a/apps/api/src/app/services/PointBalanceService.ts b/apps/api/src/app/services/PointBalanceService.ts new file mode 100644 index 000000000..3463cc14e --- /dev/null +++ b/apps/api/src/app/services/PointBalanceService.ts @@ -0,0 +1,29 @@ +import { Participant, PoolDocument } from '@thxnetwork/api/models'; + +async function add(pool: PoolDocument, account: TAccount, amount: number) { + const participant = await Participant.findOne({ poolId: pool._id, sub: account.sub }); + const balance = participant ? Number(participant.balance) + Number(amount) : Number(amount); + + await Participant.updateOne( + { poolId: String(pool._id), sub: account.sub }, + { poolId: String(pool._id), sub: account.sub, balance }, + { upsert: true }, + ); +} + +async function subtract(pool: PoolDocument, account: TAccount, price: number) { + if (!price) return; + + const participant = await Participant.findOne({ poolId: pool._id, sub: account.sub }); + if (!participant) return; + + const balance = Number(participant.balance) >= price ? Number(participant.balance) - price : 0; + + await Participant.updateOne( + { poolId: String(pool._id), sub: account.sub }, + { poolId: String(pool._id), sub: account.sub, balance }, + { upsert: true }, + ); +} + +export default { add, subtract }; diff --git a/apps/api/src/app/services/PoolService.ts b/apps/api/src/app/services/PoolService.ts new file mode 100644 index 000000000..f483e1de5 --- /dev/null +++ b/apps/api/src/app/services/PoolService.ts @@ -0,0 +1,406 @@ +import { AccessTokenKind, ChainId, CollaboratorInviteState, OAuthDiscordScope } from '@thxnetwork/common/enums'; +import { v4 } from 'uuid'; +import { AccountVariant } from '@thxnetwork/common/enums'; +import { DASHBOARD_URL } from '../config/secrets'; +import { DEFAULT_COLORS, DEFAULT_ELEMENTS } from '@thxnetwork/common/constants'; +import { logger } from '../util/logger'; +import { getsigningSecret } from '../util/signingsecret'; +import { + Pool, + PoolDocument, + RewardCoin, + RewardNFT, + Collaborator, + CollaboratorDocument, + Client, + DiscordGuild, + Identity, + Participant, + QuestInvite, + QuestWeb3, + QuestCustom, + RewardCustom, + Widget, + QuestSocial, + QuestDaily, + CouponCode, + WalletDocument, +} from '@thxnetwork/api/models'; + +import AccountProxy from '../proxies/AccountProxy'; +import DiscordDataProxy from '../proxies/DiscordDataProxy'; +import MailService from './MailService'; +import SafeService from './SafeService'; +import ParticipantService from './ParticipantService'; +import DiscordService from './DiscordService'; +import { getContract } from './ContractService'; + +export const ADMIN_ROLE = '0x0000000000000000000000000000000000000000000000000000000000000000'; + +async function isAudienceAllowed(aud: string, poolId: string) { + return !!(await Client.exists({ clientId: aud, poolId })); +} + +async function isSubjectAllowed(sub: string, poolId: string) { + const isOwner = await Pool.exists({ + _id: poolId, + sub, + }); + const isCollaborator = await Collaborator.exists({ sub, poolId, state: CollaboratorInviteState.Accepted }); + return isOwner || isCollaborator; +} + +async function getById(id: string) { + const pool = await Pool.findById(id); + const safe = await SafeService.findOneByPool(pool, pool.chainId); + pool.safe = safe; + return pool; +} + +function getByAddress(address: string) { + return Pool.findOne({ address }); +} + +async function deploy(sub: string, title: string): Promise { + const pool = await Pool.create({ + sub, + token: v4(), + signingSecret: getsigningSecret(64), + settings: { + title, + description: '', + isArchived: false, + isWeeklyDigestEnabled: true, + isTwitterSyncEnabled: false, + defaults: { + conditionalRewards: { title: '', description: '', amount: 50 }, + }, + authenticationMethods: [ + AccountVariant.EmailPassword, + AccountVariant.Metamask, + AccountVariant.SSOGoogle, + AccountVariant.SSODiscord, + ], + }, + }); + + await Widget.create({ + uuid: v4(), + poolId: pool._id, + align: 'right', + message: 'Hi there!👋 Click me to complete quests and earn rewards...', + domain: 'https://www.example.com', + theme: JSON.stringify({ elements: DEFAULT_ELEMENTS, colors: DEFAULT_COLORS }), + }); + + return Pool.findByIdAndUpdate(pool._id, { 'settings.slug': String(pool._id) }, { new: true }); +} + +async function getAllBySub(sub: string): Promise { + let pools = await Pool.find({ sub }); + + // Only query for collabs of not already owned pools + const collaborations = await Collaborator.find({ sub, poolId: { $nin: pools.map(({ _id }) => String(_id)) } }); + const poolIds = collaborations.map((c) => c.poolId); + if (poolIds.length) { + const collaborationPools = await Pool.find({ _id: poolIds }); + pools = pools.concat(collaborationPools); + } + + // Add Safes to pools + return await Promise.all( + pools.map(async (pool) => { + const safe = await SafeService.findOneByPool(pool); + return { ...pool.toJSON(), safe }; + }), + ); +} + +function getAll() { + return Pool.find({}); +} + +async function balanceOf(safe: WalletDocument) { + const contract = getContract('USDC', safe.chainId, safe.address); + return await contract.balanceOf(safe.address); +} + +async function countByNetwork(chainId: ChainId) { + return Pool.countDocuments({ chainId }); +} + +async function find(model: any, pool: PoolDocument) { + return await model.find({ poolId: String(pool._id) }); +} + +async function findOwner(pool: PoolDocument) { + const account = await AccountProxy.findById(pool.sub); + account.tokens = account.tokens.map(({ kind, expiry, scopes }) => ({ kind, expiry, scopes } as TToken)); + return account; +} + +async function getQuestCount(pool: PoolDocument) { + const result = await Promise.all( + [QuestDaily, QuestInvite, QuestSocial, QuestCustom, QuestWeb3].map(async (model) => await find(model, pool)), + ); + return Array.from(new Set(result.flat(1))); +} + +async function getRewardCount(pool: PoolDocument) { + const result = await Promise.all( + [RewardCoin, RewardNFT, RewardCustom].map(async (model) => await find(model, pool)), + ); + return Array.from(new Set(result.flat(1))); +} + +async function findIdentities(pool: PoolDocument, page: number, limit: number) { + const startIndex = (page - 1) * limit; + const endIndex = page * limit; + const total = await Identity.find({ poolId: pool._id }).countDocuments().exec(); + + const identities = { + previous: startIndex > 0 && { + page: page - 1, + }, + next: endIndex < total && { + page: page + 1, + }, + limit, + total, + results: await Identity.aggregate([ + { $match: { poolId: String(pool._id) } }, + { $skip: startIndex }, + { $limit: limit }, + ]).exec(), + }; + + const subs = identities.results.filter(({ sub }) => !!sub).map(({ sub }) => sub); + const accounts = await AccountProxy.find({ subs }); + + identities.results = identities.results.map((identity: TIdentity) => ({ + ...identity, + account: accounts.find(({ sub }) => sub === identity.sub), + })); + + return identities; +} + +async function findCouponCodes( + { couponRewardId, query }: { couponRewardId: string; query: string }, + page: number, + limit: number, +) { + const startIndex = (page - 1) * limit; + const endIndex = page * limit; + const $match = { couponRewardId }; + if (query && query.length > 1) { + $match['code'] = { $regex: query, $options: 'i' }; + } + + const total = await CouponCode.find($match).countDocuments(); + const results = await CouponCode.aggregate([{ $match }, { $skip: startIndex }, { $limit: limit }]).exec(); + // Get subs for results + const subs = results.map(({ sub }) => sub); + // Get accounts for subs + const accounts = await AccountProxy.find({ subs }); + + return { + previous: startIndex > 0 && { + page: page - 1, + }, + next: endIndex < total && { + page: page + 1, + }, + total, + results: results.map((result) => { + const account = accounts.find(({ sub }) => sub === result.sub); + result.account = account; + return result; + }), + }; +} + +async function findParticipants(pool: PoolDocument, page: number, limit: number, query = '') { + const startIndex = (page - 1) * limit; + const endIndex = page * limit; + const poolId = String(pool._id); + const total = await Participant.countDocuments({ poolId }); + const $match = { poolId }; + + let accounts = [], + subs = []; + + // If a query is provided first get the accounts and subs based on the query + if (query) { + accounts = await AccountProxy.find({ query }); + subs = accounts.map((a) => a.sub); + $match['sub'] = { $in: subs }; + } + + const results = await Participant.aggregate([ + { $match }, + { + $addFields: { + rankSort: { + $cond: { + if: { $gt: ['$rank', 0] }, + then: '$rank', + else: Number.MAX_SAFE_INTEGER, + }, + }, + }, + }, + { $sort: { rankSort: 1 } }, + { $skip: startIndex }, + { $limit: limit }, + ]).exec(); + + // If a query was provided dont get accounts and subs based on participants + if (!query) { + subs = results.map((p) => p.sub); + accounts = await AccountProxy.find({ subs }); + } + + // Format the output + const participants = { + previous: startIndex > 0 && { + page: page - 1, + }, + next: endIndex < total && { + page: page + 1, + }, + total, + results, + }; + + const guild = await DiscordService.getGuild(poolId); + + participants.results = await Promise.all( + participants.results.map(async (participant) => { + let account: TAccount; + + try { + account = accounts.find((a) => a.sub === participant.sub); + account.tokens = await Promise.all( + account.tokens.map(async (token: TToken) => + ParticipantService.findUser(token, { userId: token.userId, guildId: guild && guild.id }), + ), + ); + } catch (error) { + logger.error(error); + } + + return { + ...participant, + account: account && { + email: account.email, + username: account.username, + profileImg: account.profileImg, + variant: account.variant, + tokens: account.tokens, + }, + }; + }), + ); + + return participants; +} + +async function getParticipantCount(pool: PoolDocument) { + return await Participant.countDocuments({ poolId: pool._id }); +} + +async function inviteCollaborator(pool: PoolDocument, email: string) { + const uuid = v4(); + let collaborator = await Collaborator.findOne({ email, poolId: pool._id }); + + if (collaborator) { + collaborator = await Collaborator.findByIdAndUpdate(collaborator._id, { uuid }, { new: true }); + } else { + collaborator = await Collaborator.create({ + email, + uuid, + poolId: pool._id, + state: CollaboratorInviteState.Pending, + }); + } + + const url = new URL(DASHBOARD_URL); + url.pathname = 'collaborator'; + url.searchParams.append('poolId', pool._id); + url.searchParams.append('collaboratorRequestToken', collaborator.uuid); + + await MailService.send( + email, + `👋 Collaboration Request: ${pool.settings.title}`, + `

Hi!👋

You have received a collaboration request for Quest & Reward campaign: ${pool.settings.title}

`, + { src: url.href, text: 'Accept Request' }, + ); + + return collaborator; +} + +async function getAccountGuilds(account: TAccount) { + // Try as this is potentially rate limited due to subsequent GET pool for id requests + try { + const token = await AccountProxy.getToken(account, AccessTokenKind.Discord, [ + OAuthDiscordScope.Identify, + OAuthDiscordScope.Guilds, + ]); + return DiscordDataProxy.getGuilds(token); + } catch (error) { + return []; + } +} + +async function findGuilds(pool: PoolDocument) { + const account = await AccountProxy.findById(pool.sub); + const userGuilds = await getAccountGuilds(account); + const guilds = await DiscordGuild.find({ poolId: pool._id }); + const promises = userGuilds.map(async (userGuild: { id: string; name: string }) => { + const guild = guilds.find(({ guildId }) => guildId === userGuild.id); + return await DiscordDataProxy.getGuild({ + ...(guild && guild.toJSON()), + ...userGuild, + guildId: userGuild.id, + poolId: pool._id, + isConnected: !!guild, + }); + }); + + return await Promise.all(promises); +} + +async function findCollaborators(pool: PoolDocument) { + const collabs = await Collaborator.find({ poolId: pool._id }); + const promises = collabs.map(async (collaborator: CollaboratorDocument) => { + if (collaborator.sub) { + const account = await AccountProxy.findById(collaborator.sub); + return { ...collaborator.toJSON(), account }; + } + return collaborator; + }); + return await Promise.all(promises); +} + +export default { + isAudienceAllowed, + isSubjectAllowed, + getById, + getByAddress, + deploy, + balanceOf, + getAllBySub, + getAll, + countByNetwork, + getParticipantCount, + getQuestCount, + getRewardCount, + findOwner, + findIdentities, + findParticipants, + findGuilds, + findCollaborators, + findCouponCodes, + inviteCollaborator, +}; diff --git a/apps/api/src/app/services/QuestCustomService.ts b/apps/api/src/app/services/QuestCustomService.ts new file mode 100644 index 000000000..cc54a5ca4 --- /dev/null +++ b/apps/api/src/app/services/QuestCustomService.ts @@ -0,0 +1,126 @@ +import { + QuestCustom, + QuestCustomDocument, + QuestCustomEntry, + Identity, + IdentityDocument, + Event, + WalletDocument, +} from '@thxnetwork/api/models'; +import { IQuestService } from './interfaces/IQuestService'; + +export default class QuestCustomService implements IQuestService { + models = { + quest: QuestCustom, + entry: QuestCustomEntry, + }; + + async findEntryMetadata({ quest }: { quest: QuestCustomDocument }) { + const uniqueParticipantIds = await QuestCustomEntry.countDocuments({ + questId: String(quest._id), + }).distinct('sub'); + + return { participantCount: uniqueParticipantIds.length }; + } + + async isAvailable({ + quest, + account, + }: { + quest: QuestCustomDocument; + wallet?: WalletDocument; + account?: TAccount; + data: Partial; + }): Promise { + const entries = await this.findAllEntries({ quest, account }); + if (quest.limit && entries.length >= quest.limit) { + return { result: false, reason: 'Quest entry limit has been reached.' }; + } + + return { result: true, reason: '' }; + } + + async getAmount({ quest }: { quest: QuestCustomDocument; wallet: WalletDocument; account: TAccount }) { + return quest.amount; + } + + async decorate({ + quest, + account, + data, + }: { + quest: QuestCustomDocument; + account?: TAccount; + data: Partial; + }) { + const entries = await this.findAllEntries({ quest, account }); + const identities = await this.findIdentities({ quest, account }); + const events = await this.findEvents({ quest, identities }); + const isAvailable = await this.isAvailable({ quest, account, data }); + const pointsAvailable = quest.limit ? (quest.limit - entries.length) * quest.amount : quest.amount; + + return { + ...quest, + eventName: '', // FK Deprecrates March 15th 2024 + isAvailable: isAvailable.result, + pointsAvailable, + entries, + events, + }; + } + + async getValidationResult({ + quest, + account, + }: { + quest: QuestCustomDocument; + account: TAccount; + data: Partial; + }): Promise<{ reason: string; result: boolean }> { + // See if there are identities + const identities = await this.findIdentities({ quest, account }); + if (!identities.length) { + return { + result: false, + reason: 'No identity connected to this account. Please ask for this in your community!', + }; + } + + // Find existing entries for this quest and check optional limit + const entries = await this.findAllEntries({ quest, account }); + if (quest.limit && entries.length >= quest.limit) { + return { result: false, reason: 'Quest entry limit has been reached' }; + } + + // Find events for this quest and the identities connected to the account + const events = await this.findEvents({ quest, identities }); + if (entries.length >= events.length) { + return { result: false, reason: 'Insufficient custom events found for this quest' }; + } + + if (entries.length < events.length) return { result: true, reason: '' }; + } + + private async findAllEntries({ quest, account }: { quest: QuestCustomDocument; account: TAccount }) { + if (!account) return []; + return await this.models.entry.find({ + questId: quest._id, + sub: account.sub, + isClaimed: true, + }); + } + + private async findIdentities({ quest, account }: { quest: QuestCustomDocument; account: TAccount }) { + if (!account || !account.sub) return []; + return await Identity.find({ poolId: quest.poolId, sub: account.sub }); + } + + private async findEvents({ quest, identities }: { quest: QuestCustomDocument; identities: IdentityDocument[] }) { + if (!identities.length) return []; + return await Event.find({ + identityId: { $in: identities.map(({ _id }) => String(_id)) }, + poolId: quest.poolId, + name: quest.eventName, + }).limit(quest.limit || null); + } +} diff --git a/apps/api/src/app/services/QuestDailyService.ts b/apps/api/src/app/services/QuestDailyService.ts new file mode 100644 index 000000000..ae0c488be --- /dev/null +++ b/apps/api/src/app/services/QuestDailyService.ts @@ -0,0 +1,206 @@ +import { Event, Identity, QuestDaily, QuestDailyEntry } from '@thxnetwork/api/models'; +import { IQuestService } from './interfaces/IQuestService'; + +const ONE_DAY_MS = 86400 * 1000; // 24 hours in milliseconds + +export default class QuestDailyService implements IQuestService { + models = { + quest: QuestDaily, + entry: QuestDailyEntry, + }; + + async findEntryMetadata({ quest }: { quest: TQuestDaily }) { + const uniqueParticipantIds = await this.models.entry + .countDocuments({ + questId: String(quest._id), + }) + .distinct('sub'); + + return { participantCount: uniqueParticipantIds.length }; + } + + async decorate({ + quest, + account, + data, + }: { + quest: TQuestDaily; + data: Partial; + account?: TAccount; + }): Promise< + TQuestDaily & { + isAvailable: boolean; + amount: number; + entries: TQuestDailyEntry[]; + claimAgainDuration: number; + } + > { + const amount = await this.getAmount({ quest, account }); + const entries = account ? await this.findEntries({ quest, account }) : []; + const claimAgainTime = entries.length ? new Date(entries[0].createdAt).getTime() + ONE_DAY_MS : null; + const now = Date.now(); + const isAvailable = await this.isAvailable({ quest, account, data }); + + return { + ...quest, + isAvailable: isAvailable.result, + amount, + entries, + claimAgainDuration: + claimAgainTime && claimAgainTime - now > 0 ? Math.floor((claimAgainTime - now) / 1000) : null, // Convert and floor to S, + }; + } + + async isAvailable({ + quest, + account, + data, + }: { + quest: TQuestDaily; + account: TAccount; + data: Partial; + }): Promise { + if (!account) return { result: true, reason: '' }; + + const now = Date.now(), + start = now - ONE_DAY_MS, + end = now; + + // Check for IP as we limit to 1 per IP per day (if an ip is passed) + if (data.metadata && data.metadata.ip) { + const isCompletedForIP = !!(await QuestDailyEntry.exists({ + 'questId': quest._id, + 'createdAt': { $gt: new Date(start), $lt: new Date(end) }, + 'metadata.ip': data.metadata.ip, + })); + if (isCompletedForIP) { + return { + result: false, + reason: 'You have completed this quest from this IP within the last 24 hours.', + }; + } + } + + const isCompleted = await QuestDailyEntry.findOne({ + questId: quest._id, + sub: account.sub, + createdAt: { $gt: new Date(start), $lt: new Date(end) }, + }); + if (!isCompleted) return { result: true, reason: '' }; + + return { result: false, reason: 'You have completed this quest within the last 24 hours.' }; + } + + async getAmount({ quest, account }: { quest: TQuestDaily; account: TAccount }): Promise { + if (!account) return quest.amounts[0]; + + const claims = await this.findEntries({ quest, account }); + const amountIndex = + claims.length >= quest.amounts.length ? claims.length % quest.amounts.length : claims.length; + return quest.amounts[amountIndex]; + } + + async getValidationResult({ + quest, + account, + }: { + quest: TQuestDaily; + account: TAccount; + data: Partial; + }): Promise { + const now = Date.now(), + start = now - ONE_DAY_MS, + end = now; + + const entry = await QuestDailyEntry.findOne({ + questId: quest._id, + sub: account.sub, + createdAt: { $gt: new Date(start), $lt: new Date(end) }, + }); + + // If an entry has been found the user needs to wait + if (entry) { + return { result: false, reason: `Already completed within the last 24 hours.` }; + } + + // If no entry has been found and no event is required the entry is allowed to be created + if (!quest.eventName) { + return { result: true, reason: '' }; + } + + // If an event is required we check if there is an event found within the time window + const identities = await this.findIdentities({ quest, account }); + if (!identities.length) { + return { + result: false, + reason: 'No identity connected to this account. Please ask for this in your community!', + }; + } + + const identityIds = identities.map(({ _id }) => String(_id)); + const events = await Event.find({ + name: quest.eventName, + poolId: quest.poolId, + identityId: { $in: identityIds }, + createdAt: { $gt: new Date(start), $lt: new Date(end) }, + }); + + // If no events are found we invalidate + if (!events.length) { + return { result: false, reason: 'No events found for this account' }; + } + + // If events are found we validate true + else { + return { result: true, reason: '' }; + } + } + + private async findIdentities({ quest, account }: { quest: TQuestDaily; account: TAccount }) { + return await Identity.find({ sub: account.sub, poolId: quest.poolId }); + } + + private async findEntries({ account, quest }: { account: TAccount; quest: TQuestDaily }) { + const claims = []; + const now = Date.now(), + start = now - ONE_DAY_MS, + end = now; + + let lastEntry = await this.getLastEntry(account, quest, start, end); + if (!lastEntry) return []; + claims.push(lastEntry); + + while (lastEntry) { + const timestamp = new Date(lastEntry.createdAt).getTime(); + lastEntry = await QuestDailyEntry.findOne({ + questId: quest._id, + sub: account.sub, + createdAt: { + $gt: new Date(timestamp - ONE_DAY_MS * 2), + $lt: new Date(timestamp - ONE_DAY_MS), + }, + }); + if (!lastEntry) break; + claims.push(lastEntry); + } + + return claims; + } + + private async getLastEntry(account: TAccount, quest: TQuestDaily, start: number, end: number) { + let lastEntry = await QuestDailyEntry.findOne({ + questId: quest._id, + sub: account.sub, + createdAt: { $gt: new Date(start), $lt: new Date(end) }, + }); + + if (!lastEntry) { + lastEntry = await QuestDailyEntry.findOne({ + questId: quest._id, + sub: account.sub, + createdAt: { $gt: new Date(start - ONE_DAY_MS), $lt: new Date(end - ONE_DAY_MS) }, + }); + } + return lastEntry; + } +} diff --git a/apps/api/src/app/services/QuestDiscordService.ts b/apps/api/src/app/services/QuestDiscordService.ts new file mode 100644 index 000000000..d746ff5b4 --- /dev/null +++ b/apps/api/src/app/services/QuestDiscordService.ts @@ -0,0 +1,193 @@ +import { QuestSocialEntry, QuestSocial, DiscordMessage } from '@thxnetwork/api/models'; +import { QuestSocialRequirement } from '@thxnetwork/common/enums'; +import { IQuestService } from './interfaces/IQuestService'; +import { requirementMap } from './maps/quests'; +import QuestSocialService from './QuestSocialService'; +import QuestService from './QuestService'; + +type TRestartDates = { now: Date; start: Date; endDay: Date; end: Date }; + +export default class QuestDiscordService implements IQuestService { + models = { + quest: QuestSocial, + entry: QuestSocialEntry, + }; + + findEntryMetadata(options: { quest: TQuestSocial }) { + return {}; + } + + async decorate({ + quest, + account, + data, + }: { + quest: TQuestSocial; + account: TAccount; + data: Partial; + }): Promise< + TQuestSocial & { + messages: TDiscordMessage[]; + restartDates: TRestartDates; + amount: number; + isAvailable: boolean; + } + > { + const amount = await this.getAmount({ quest, account }); + const isAvailable = await this.isAvailable({ quest, account, data }); + const interactionMap = { + [QuestSocialRequirement.DiscordMessage]: this.getDiscordMessageParams.bind(this), + [QuestSocialRequirement.DiscordGuildJoined]: this.getDiscordParams.bind(this), + [QuestSocialRequirement.DiscordGuildRole]: this.getDiscordParams.bind(this), + }; + const extraParams = await interactionMap[quest.interaction]({ quest, account }); + + return { + ...quest, + amount, + isAvailable: isAvailable.result, + contentMetadata: quest.contentMetadata && JSON.parse(quest.contentMetadata), + ...extraParams, + }; + } + + async isAvailable({ + quest, + account, + }: { + quest: TQuestSocial; + account?: TAccount; + data: Partial; + }): Promise { + const map = { + [QuestSocialRequirement.DiscordMessage]: this.isAvailableMessage.bind(this), + [QuestSocialRequirement.DiscordGuildJoined]: this.isAvailableDefault.bind(this), + [QuestSocialRequirement.DiscordGuildRole]: this.isAvailableDefault.bind(this), + }; + return await map[quest.interaction]({ quest, account }); + } + + private async isAvailableDefault({ + quest, + account, + data, + }: { + quest: TQuestSocial; + account?: TAccount; + data: Partial; + }) { + if (!account) return { result: true, reason: '' }; + + // We use the default more generic QuestSocialService here since we want to + // validate for platformUserIds as well + return await new QuestSocialService().isAvailable({ quest, account, data }); + } + + private async isAvailableMessage({ quest, account }: { quest: TQuestSocial; account?: TAccount }) { + const { pointsAvailable } = await this.getMessagePoints({ quest, account }); + const isAvailable = pointsAvailable > 0; + if (isAvailable) return { result: true, reason: '' }; + + return { result: false, reason: 'You have not earned any points with messages yet.' }; + } + + async getAmount({ account, quest }: { quest: TQuestSocial; account?: TAccount }): Promise { + const interactionMap = { + [QuestSocialRequirement.DiscordMessage]: this.getMessagePoints.bind(this), + [QuestSocialRequirement.DiscordGuildJoined]: this.getPoints.bind(this), + [QuestSocialRequirement.DiscordGuildRole]: this.getPoints.bind(this), + }; + const { pointsAvailable } = await interactionMap[quest.interaction]({ quest, account }); + return pointsAvailable; + } + + async getValidationResult(options: { + quest: TQuestSocial; + account: TAccount; + data: Partial; + }): Promise { + if (!options.quest.interaction) return { result: false, reason: '' }; + return await requirementMap[options.quest.interaction](options.account, options.quest); + } + + private getRestartDates(quest: TQuestSocial) { + const { days } = JSON.parse(quest.contentMetadata); + const now = new Date(); + const questCreatedAt = new Date(quest.createdAt); + const totalDaysRunning = Math.floor( + Math.ceil(now.getTime() / 1000 - questCreatedAt.getTime() / 1000) / 60 / 60 / 24, + ); + const daysRunning = totalDaysRunning % days; + const msRunning = daysRunning * 24 * 60 * 60 * 1000; + + const start = new Date(now.getTime() - msRunning); + start.setUTCHours(0, 0, 0, 0); + + const end = new Date(start.getTime() + days * 24 * 60 * 60 * 1000); + const endDay = new Date(now); + endDay.setUTCHours(23, 59, 59, 999); + + return { now, start, endDay, end }; + } + + private async getDiscordParams({ quest }: { quest: TQuestSocial; account: TAccount }) { + return { pointsAvailable: quest.amount }; + } + + private async getDiscordMessageParams({ quest, account }: { quest: TQuestSocial; account: TAccount }) { + const restartDates = this.getRestartDates(quest); + const messages = await this.getMessages({ account, quest, start: restartDates.start }); + const points = await this.getMessagePoints({ + quest, + account, + }); + + return { + restartDates, + messages, + ...points, + }; + } + + private async getMessages({ quest, account, start }: { quest: TQuestSocial; account: TAccount; start: Date }) { + if (!account) return []; + + const userId = QuestService.findUserIdForInteraction(account, quest.interaction); + return await DiscordMessage.find({ + guildId: quest.content, + memberId: userId, + createdAt: { $gte: new Date(start).toISOString() }, + }); + } + + private async getPoints({ quest }) { + return { pointsAvailable: quest.amount }; + } + + private async getMessagePoints({ quest, account }) { + if (!account) return { pointsAvailable: 0, pointsClaimed: 0 }; + + const { start, end } = this.getRestartDates(quest); + const platformUserId = QuestService.findUserIdForInteraction(account, quest.interaction); + const claims = await QuestSocialEntry.find({ + 'questId': String(quest._id), + 'metadata.platformUserId': platformUserId, + 'createdAt': { + $gte: start, + $lt: end, + }, + }).sort({ createdAt: -1 }); + const [claim] = claims; + const pointsClaimed = claims.reduce((total, claim) => total + Number(claim.amount), 0); + + // Only find messages created after the last claim if one exists + const messages = await DiscordMessage.find({ + guildId: quest.content, + memberId: platformUserId, + createdAt: { $gte: claim ? claim.createdAt : start, $lt: end }, + }); + const pointsAvailable = messages.length * quest.amount; + + return { pointsClaimed, pointsAvailable }; + } +} diff --git a/apps/api/src/app/services/QuestGitcoinService.ts b/apps/api/src/app/services/QuestGitcoinService.ts new file mode 100644 index 000000000..73825fe5b --- /dev/null +++ b/apps/api/src/app/services/QuestGitcoinService.ts @@ -0,0 +1,75 @@ +import { QuestGitcoin, QuestGitcoinEntry } from '@thxnetwork/api/models'; +import { IQuestService } from './interfaces/IQuestService'; +import GitcoinService from './GitcoinService'; + +export default class QuestGitcoinService implements IQuestService { + models = { + quest: QuestGitcoin, + entry: QuestGitcoinEntry, + }; + + findEntryMetadata(options: { quest: TQuestGitcoin }) { + return {}; + } + + async decorate({ + quest, + account, + data, + }: { + quest: TQuestGitcoin; + account?: TAccount; + data: Partial; + }): Promise { + const isAvailable = await this.isAvailable({ quest, account, data }); + return { ...quest, isAvailable: isAvailable.result }; + } + + async isAvailable({ + quest, + account, + data, + }: { + quest: TQuestGitcoin; + account?: TAccount; + data: Partial; + }): Promise { + if (!account) return { result: true, reason: '' }; + + const ids: { [key: string]: string }[] = [{ sub: account.sub }]; + if (data.metadata && data.metadata.address) ids.push({ 'metadata.address': data.metadata.address }); + + const isCompleted = await QuestGitcoinEntry.exists({ + questId: quest._id, + $or: ids, + }); + if (!isCompleted) return { result: true, reason: '' }; + + return { result: false, reason: 'You have completed this quest with this account and/or address already.' }; + } + + async getAmount({ quest }: { quest: TQuestGitcoin; account: TAccount }): Promise { + return quest.amount; + } + + async getValidationResult({ + quest, + data, + }: { + quest: TQuestGitcoin; + account: TAccount; + data: Partial; + }): Promise { + if (!data.metadata.address) return { result: false, reason: 'Could not find an address during validation.' }; + if (data.metadata.score < quest.score) { + const score = data.metadata.score.toString() || 0; + const reason = `Your score ${score}/100 does not meet the minimum of ${quest.score}/100.`; + return { result: false, reason }; + } + if (data.metadata.score >= quest.score) return { result: true, reason: '' }; + } + + async getScore(scorerId: number, address: string) { + return await GitcoinService.getScoreUniqueHumanity(scorerId, address); + } +} diff --git a/apps/api/src/app/services/QuestInviteService.ts b/apps/api/src/app/services/QuestInviteService.ts new file mode 100644 index 000000000..61c31393b --- /dev/null +++ b/apps/api/src/app/services/QuestInviteService.ts @@ -0,0 +1,44 @@ +import { QuestInvite, QuestInviteEntry } from '@thxnetwork/api/models'; +import { IQuestService } from './interfaces/IQuestService'; + +export default class QuestInviteService implements IQuestService { + models = { + quest: QuestInvite, + entry: QuestInviteEntry, + }; + + findEntryMetadata(options: { quest: TQuestInvite }) { + return {}; + } + + async decorate({ quest }: { quest: TQuestInvite; data: Partial }): Promise { + return { + ...quest, + pathname: quest.pathname, + successUrl: quest.successUrl, + }; + } + + async isAvailable(options: { + quest: TQuestInvite; + account?: TAccount; + data: Partial; + }): Promise { + return { result: false, reason: 'Not implemented' }; + } + + async getAmount({ quest }: { quest: TQuestInvite; account: TAccount }): Promise { + return quest.amount; + } + + async getValidationResult(options: { + quest: TQuestInvite; + account: TAccount; + data: Partial; + }): Promise { + return { + result: false, + reason: 'Sorry, support not yet implemented...', + }; + } +} diff --git a/apps/api/src/app/services/QuestService.ts b/apps/api/src/app/services/QuestService.ts new file mode 100644 index 000000000..e33ee0b33 --- /dev/null +++ b/apps/api/src/app/services/QuestService.ts @@ -0,0 +1,260 @@ +import { JobType, QuestSocialRequirement, QuestVariant } from '@thxnetwork/common/enums'; +import { PoolDocument, Participant } from '@thxnetwork/api/models'; +import { v4 } from 'uuid'; +import { agenda } from '../util/agenda'; +import { logger } from '../util/logger'; +import { Job } from '@hokify/agenda'; +import { serviceMap } from './interfaces/IQuestService'; +import { tokenInteractionMap } from './maps/quests'; +import { NODE_ENV } from '../config/secrets'; +import PoolService from './PoolService'; +import NotificationService from './NotificationService'; +import PointBalanceService from './PointBalanceService'; +import LockService from './LockService'; +import ImageService from './ImageService'; +import AccountProxy from '../proxies/AccountProxy'; +import ParticipantService from './ParticipantService'; +import THXService from './THXService'; + +export default class QuestService { + static async count({ poolId }) { + const variants = Object.keys(QuestVariant).filter((v) => !isNaN(Number(v))); + const counts = await Promise.all( + variants.map(async (variant: string) => { + const Quest = serviceMap[variant].models.quest; + return await Quest.countDocuments({ poolId, isPublished: true }); + }), + ); + return counts.reduce((acc, count) => acc + count, 0); + } + + static async list({ pool, data, account }: { pool: PoolDocument; data: Partial; account?: TAccount }) { + const questVariants = Object.keys(QuestVariant).filter((v) => !isNaN(Number(v))); + const author = await AccountProxy.findById(pool.sub); + const callback: any = async (variant: QuestVariant) => { + const Quest = serviceMap[variant].models.quest; + const quests = await Quest.find({ + poolId: pool._id, + variant, + isPublished: true, + $or: [ + // Include quests with expiryDate less than or equal to now + { expiryDate: { $exists: true, $gte: new Date() } }, + // Include quests with no expiryDate + { expiryDate: { $exists: false } }, + ], + }); + + return await Promise.all( + quests.map(async (q) => { + try { + const quest = q.toJSON() as TQuest; + const decorated = await serviceMap[variant].decorate({ quest, account, data }); + const isLocked = await LockService.getIsLocked(quest.locks, account); + const isExpired = this.isExpired(quest); + const QuestEntry = serviceMap[variant].models.entry; + const distinctSubs = await QuestEntry.countDocuments({ questId: q.id }).distinct('sub'); + return { + ...decorated, + entryCount: distinctSubs.length, + author: { username: author.username }, + isLocked, + isExpired, + }; + } catch (error) { + logger.error(error); + } + }), + ); + }; + + return await Promise.all(questVariants.map(callback)); + } + + static async update(quest: TQuest, updates: Partial, file?: Express.Multer.File) { + if (file) { + updates.image = await ImageService.upload(file); + } + + // We only want to notify when the quest is set to published (and not updated while published already) + if (updates.isPublished && Boolean(updates.isPublished) !== quest.isPublished) { + await NotificationService.notify(quest.variant, { + ...quest, + ...updates, + image: updates.image || quest.image, + }); + } + + return await this.updateById(quest.variant, quest._id, updates); + } + + static async create(variant: QuestVariant, poolId: string, data: Partial, file?: Express.Multer.File) { + if (file) { + data.image = await ImageService.upload(file); + } + + const Quest = serviceMap[variant].models.quest; + const quest = await Quest.create({ ...data, poolId, variant, uuid: v4() }); + + if (data.isPublished) { + await NotificationService.notify(variant, quest); + } + + return quest; + } + + static findById(variant: QuestVariant, questId: string) { + const Quest = serviceMap[variant].models.quest; + return Quest.findById(questId); + } + + static updateById(variant: QuestVariant, questId: string, options: Partial) { + const Quest = serviceMap[variant].models.quest; + return Quest.findByIdAndUpdate(questId, options, { new: true }); + } + + static getAmount(variant: QuestVariant, quest: TQuest, account: TAccount) { + return serviceMap[variant].getAmount({ quest, account }); + } + + static isExpired(quest: TQuest) { + return quest.expiryDate ? new Date(quest.expiryDate).getTime() < Date.now() : false; + } + + static async isAvailable( + variant: QuestVariant, + options: { + quest: TQuest; + account?: TAccount; + data: Partial; + }, + ): Promise { + if (!options.quest.isPublished) { + return { result: false, reason: 'Quest has not been published.' }; + } + + const isExpired = this.isExpired(options.quest); + if (isExpired) return { result: false, reason: 'Quest has expired.' }; + + const isLocked = await LockService.getIsLocked(options.quest.locks, options.account); + if (isLocked) return { result: false, reason: 'Quest is locked.' }; + + return await serviceMap[variant].isAvailable(options); + } + + static async isRealUser( + variant: QuestVariant, + options: { quest: TQuest; account: TAccount; data: Partial }, + ) { + // Skip recaptcha check in test environment + if (NODE_ENV === 'test') return { result: true, reasons: '' }; + + // Define the recaptcha action for this quest variant + const recaptchaAction = `QUEST_${QuestVariant[variant].toUpperCase()}_ENTRY_CREATE`; + + // Update the participant's risk score + const { riskAnalysis } = await ParticipantService.updateRiskScore(options.account, options.quest.poolId, { + token: options.data.recaptcha, + recaptchaAction, + }); + + logger.info( + 'ReCaptcha result' + + JSON.stringify({ + sub: options.account.sub, + poolId: options.quest.poolId, + riskAnalysis, + recaptchaAction, + }), + ); + + // Defaults: 0.1, 0.3, 0.7 and 0.9. Ranges from 0 (Bot) to 1 (User) + if (riskAnalysis.score >= 0.9) { + return { result: true, reasons: '' }; + } + + return { result: false, reason: 'This request has been indentified as potentially automated.' }; + } + + static async getValidationResult( + variant: QuestVariant, + options: { + quest: TQuest; + account: TAccount; + data: Partial; + }, + ) { + const isAvailable = await this.isAvailable(variant, options); + if (!isAvailable.result) return isAvailable; + + return await serviceMap[variant].getValidationResult(options); + } + + static async createEntryJob(job: Job) { + try { + const { variant, questId, sub, data } = job.attrs.data as any; + const Entry = serviceMap[Number(variant)].models.entry; + const account = await AccountProxy.findById(sub); + const quest = await this.findById(variant, questId); + const pool = await PoolService.getById(quest.poolId); + const amount = await this.getAmount(variant, quest, account); + + // Test availabily of quest once more as it could be completed by a job that was scheduled already + // if the jobs were created in parallel. + const isAvailable = await this.isAvailable(variant, { quest, account, data }); + if (!isAvailable.result) throw new Error(isAvailable.reason); + + // Create the quest entry + const entry = await Entry.create({ + ...data, + sub: account.sub, + amount, + questId: String(quest._id), + poolId: pool._id, + uuid: v4(), + } as TQuestEntry); + if (!entry) throw new Error('Entry creation failed.'); + + // Should make sure quest entry is properly created + await PointBalanceService.add(pool, account, amount); + await NotificationService.sendQuestEntryNotification(pool, quest, account, amount); + + // Register THX onboarding campaign event + await THXService.createEvent(account, 'quest_entry_created'); + + // Update participant ranks async + agenda.now(JobType.UpdateParticipantRanks, { poolId: pool._id }); + } catch (error) { + logger.error(error); + } + } + + static findUserIdForInteraction(account: TAccount, interaction: QuestSocialRequirement) { + if (typeof interaction === 'undefined') return; + const { kind } = tokenInteractionMap[interaction]; + const token = account.tokens.find((token) => token.kind === kind); + + return token && token.userId; + } + + static async findEntries(quest: TQuest, { page = 1, limit = 25 }: { page: number; limit: number }) { + const skip = (page - 1) * limit; + const Entry = serviceMap[quest.variant].models.entry; + const total = await Entry.countDocuments({ questId: quest._id }); + const entries = await Entry.find({ questId: quest._id }).limit(limit).skip(skip); + const subs = entries.map((entry) => entry.sub); + const accounts = await AccountProxy.find({ subs }); + const participants = await Participant.find({ poolId: quest.poolId }); + const promises = entries.map(async (entry) => ParticipantService.decorate(entry, { accounts, participants })); + const results = await Promise.allSettled(promises); + const meta = await serviceMap[quest.variant].findEntryMetadata({ quest }); + + return { + total, + limit, + page, + meta, + results: results.filter((result) => result.status === 'fulfilled').map((result: any) => result.value), + }; + } +} diff --git a/apps/api/src/app/services/QuestSocialService.ts b/apps/api/src/app/services/QuestSocialService.ts new file mode 100644 index 000000000..289f6e03c --- /dev/null +++ b/apps/api/src/app/services/QuestSocialService.ts @@ -0,0 +1,100 @@ +import { QuestSocial, QuestSocialDocument, QuestSocialEntry } from '@thxnetwork/api/models'; +import { WalletDocument } from '@thxnetwork/api/models/Wallet'; +import { IQuestService } from './interfaces/IQuestService'; +import { requirementMap } from './maps/quests'; +import { logger } from '../util/logger'; +import { QuestVariant } from '@thxnetwork/common/enums'; + +export default class QuestSocialService implements IQuestService { + models = { + quest: QuestSocial, + entry: QuestSocialEntry, + }; + + async decorate({ + quest, + account, + data, + }: { + quest: TQuestSocial; + account?: TAccount; + data: Partial; + }): Promise { + const isAvailable = await this.isAvailable({ quest, account, data }); + + return { + ...quest, + isAvailable: isAvailable.result, + contentMetadata: quest.contentMetadata && JSON.parse(quest.contentMetadata), + }; + } + + async isAvailable({ + quest, + account, + data, + }: { + quest: TQuestSocial; + account: TAccount; + data: Partial; + }): Promise { + if (!account) return { result: true, reason: '' }; + + // We validate for both here since there are entries that only contain a sub + // and should not be claimed again. + const ids: any[] = [{ sub: account.sub }]; + if (data && data.metadata && data.metadata.platformUserId) + ids.push({ platformUserId: data.metadata.platformUserId }); + + // If no entry exist the quest is available + const isCompleted = await QuestSocialEntry.exists({ + questId: quest._id, + $or: ids, + }); + if (!isCompleted) return { result: true, reason: '' }; + + return { result: false, reason: 'You have completed this quest with this (connected) account already.' }; + } + + async getAmount({ quest }: { quest: TQuestSocial; wallet: WalletDocument; account: TAccount }): Promise { + return quest.amount; + } + + async getValidationResult({ + quest, + account, + }: { + quest: TQuestSocial; + account: TAccount; + data: Partial; + }): Promise { + try { + // Check quest requirements + const validationResult = await requirementMap[quest.interaction](account, quest); + return validationResult || { result: true, reason: '' }; + } catch (error) { + logger.error(error); + return { result: false, reason: 'We were unable to confirm the requirements for this quest.' }; + } + } + + async findEntryMetadata({ quest }: { quest: QuestSocialDocument }) { + const reachTotal = await this.getTwitterFollowerCount(quest); + const uniqueParticipantIds = await QuestSocialEntry.find({ + questId: String(quest._id), + }).distinct('sub'); + + return { reachTotal, participantCount: uniqueParticipantIds.length }; + } + + async getTwitterFollowerCount(quest: QuestSocialDocument) { + if (quest.variant !== QuestVariant.Twitter) return; + + const [result] = await QuestSocialEntry.aggregate([ + { $match: { questId: String(quest._id) } }, + { $group: { _id: null, totalFollowersCount: { $sum: '$publicMetrics.followersCount' } } }, + ]); + + return result ? result.totalFollowersCount : 0; + } +} diff --git a/apps/api/src/app/services/QuestWeb3Service.ts b/apps/api/src/app/services/QuestWeb3Service.ts new file mode 100644 index 000000000..60c98ff74 --- /dev/null +++ b/apps/api/src/app/services/QuestWeb3Service.ts @@ -0,0 +1,115 @@ +import { QuestWeb3Entry, QuestWeb3 } from '@thxnetwork/api/models'; +import { BigNumber, ethers } from 'ethers'; +import { logger } from '@thxnetwork/api/util/logger'; +import { IQuestService } from './interfaces/IQuestService'; + +export default class QuestWeb3Service implements IQuestService { + models = { + quest: QuestWeb3, + entry: QuestWeb3Entry, + }; + + findEntryMetadata(options: { quest: TQuestWeb3 }) { + return {}; + } + + async decorate({ + quest, + account, + data, + }: { + quest: TQuestWeb3; + data: Partial; + account?: TAccount; + }): Promise { + const isAvailable = await this.isAvailable({ quest, account, data }); + + return { + ...quest, + isAvailable: isAvailable.result, + amount: quest.amount, + contracts: quest.contracts, + methodName: quest.methodName, + threshold: quest.threshold, + }; + } + + async isAvailable({ + quest, + account, + data, + }: { + quest: TQuestWeb3; + account: TAccount; + data: Partial; + }): Promise { + if (!account) return { result: true, reason: '' }; + + const ids: any[] = [{ sub: account.sub }]; + if (data.metadata && data.metadata.address) ids.push({ 'metadata.address': data.metadata.address }); + + const isCompleted = await QuestWeb3Entry.exists({ + questId: quest._id, + $or: ids, + }); + if (!isCompleted) return { result: true, reason: '' }; + + return { result: false, reason: 'You have completed this quest with this account and/or address already.' }; + } + + async getAmount({ quest }: { quest: TQuestWeb3; account: TAccount }): Promise { + return quest.amount; + } + + async getValidationResult({ + quest, + account, + data, + }: { + quest: TQuestWeb3; + account: TAccount; + data: Partial; + }): Promise { + const isCompleted = await QuestWeb3Entry.exists({ + questId: quest._id, + $or: [{ sub: account.sub }, { 'metadata.address': data.metadata.address }], + }); + if (isCompleted) return { result: false, reason: 'You have claimed this quest already' }; + + const threshold = BigNumber.from(quest.threshold); + const result = BigNumber.from(data.metadata.callResult); + if (result.lt(threshold)) { + return { result: false, reason: 'Result does not meet the threshold' }; + } + + return { result: true, reason: '' }; + } + + static async getCallResult({ + quest, + data, + }: { + quest: TQuestWeb3; + account: TAccount; + data: Partial; + }) { + const { rpc, chainId, address } = data.metadata; + const contract = quest.contracts.find((c) => c.chainId === chainId); + if (!contract) return { result: false, reason: 'Smart contract not found.' }; + + const contractInstance = new ethers.Contract( + contract.address, + ['function ' + quest.methodName + '(address) view returns (uint256)'], + new ethers.providers.JsonRpcProvider(rpc), + ); + + try { + const value = await contractInstance[quest.methodName](address); + + return { result: true, reason: '', value }; + } catch (error) { + logger.error(error); + return { result: false, reason: `Smart contract call on ${quest.methodName} failed` }; + } + } +} diff --git a/apps/api/src/app/services/QuestWebhookService.ts b/apps/api/src/app/services/QuestWebhookService.ts new file mode 100644 index 000000000..798d4e0e3 --- /dev/null +++ b/apps/api/src/app/services/QuestWebhookService.ts @@ -0,0 +1,114 @@ +import { + QuestWebhook, + QuestWebhookDocument, + QuestWebhookEntry, + Identity, + WalletDocument, + Webhook, +} from '@thxnetwork/api/models'; +import { IQuestService } from './interfaces/IQuestService'; +import WebhookService from './WebhookService'; + +export default class QuestWebhookService implements IQuestService { + models = { + quest: QuestWebhook, + entry: QuestWebhookEntry, + }; + + async findEntryMetadata({ quest }: { quest: QuestWebhookDocument }) { + const uniqueParticipantIds = await QuestWebhookEntry.countDocuments({ + questId: String(quest._id), + }).distinct('sub'); + + return { participantCount: uniqueParticipantIds.length }; + } + + async isAvailable({ + quest, + account, + }: { + quest: QuestWebhookDocument; + wallet?: WalletDocument; + account?: TAccount; + data: Partial; + }): Promise { + const entries = await this.findAllEntries({ quest, account }); + if (entries.length) { + return { result: false, reason: 'Quest entry limit has been reached.' }; + } + + return { result: true, reason: '' }; + } + + async getAmount({ quest }: { quest: QuestWebhookDocument; wallet: WalletDocument; account: TAccount }) { + return quest.amount; + } + + async decorate({ + quest, + account, + data, + }: { + quest: QuestWebhookDocument; + account?: TAccount; + data: Partial; + }) { + const entries = await this.findAllEntries({ quest, account }); + const identities = await this.findIdentities({ quest, account }); + const isAvailable = await this.isAvailable({ quest, account, data }); + + return { + ...quest, + identities, + isAvailable: isAvailable.result, + entries, + }; + } + + async getValidationResult({ + quest, + account, + }: { + quest: QuestWebhookDocument; + account: TAccount; + data: Partial; + }): Promise<{ reason: string; result: boolean; data?: any }> { + // See if there are identities + const identities = await this.findIdentities({ quest, account }); + if (!identities.length) { + return { + result: false, + reason: 'No identity connected to this account. Please ask for this in your community!', + }; + } + + const webhook = await Webhook.findById(quest.webhookId); + if (!webhook) return { result: false, reason: 'Webhook no longer available.' }; + + const data = await WebhookService.request(webhook, account, quest.metadata); + if (!data) return { result: false, reason: 'Webhook validation returned nothing.' }; + if (!data.result) return { result: false, reason: 'Webhook validation was negative.' }; + if (data.result) { + return { + result: true, + reason: '', + data, + }; + } + + return { result: false, reason: 'Webhook validation request failed.' }; + } + + private async findAllEntries({ quest, account }: { quest: QuestWebhookDocument; account: TAccount }) { + if (!account) return []; + return await this.models.entry.find({ + questId: quest._id, + sub: account.sub, + }); + } + + private async findIdentities({ quest, account }: { quest: QuestWebhookDocument; account: TAccount }) { + if (!account || !account.sub) return []; + return await Identity.find({ poolId: quest.poolId, sub: account.sub }); + } +} diff --git a/apps/api/src/app/services/ReCaptchaService.ts b/apps/api/src/app/services/ReCaptchaService.ts new file mode 100644 index 000000000..7af44d027 --- /dev/null +++ b/apps/api/src/app/services/ReCaptchaService.ts @@ -0,0 +1,39 @@ +import axios from 'axios'; +import { GCLOUD_PROJECT_ID, GCLOUD_RECAPTCHA_API_KEY, GCLOUD_RECAPTCHA_SITE_KEY } from '../config/secrets'; +import { BadRequestError } from '../util/errors'; + +export default class ReCaptchaService { + static async getRiskAnalysis({ token, recaptchaAction }) { + const url = new URL('https://recaptchaenterprise.googleapis.com'); + url.pathname = `/v1/projects/${GCLOUD_PROJECT_ID}/assessments`; + + const { data } = await axios({ + method: 'POST', + url: url.toString(), + data: { + event: { + token, + expectedAction: recaptchaAction, + siteKey: GCLOUD_RECAPTCHA_SITE_KEY, + }, + }, + params: { + key: GCLOUD_RECAPTCHA_API_KEY, + }, + }); + + // Check if the token is valid. + if (!data.tokenProperties.valid) { + throw new BadRequestError('Invalid ReCAPTCHA token.'); + } + + // Check if the expected action was executed. + if (data.tokenProperties.action !== recaptchaAction) { + throw new BadRequestError('Invalid ReCAPTCHA action.'); + } + + // Get the risk score and the reason(s). + // https://cloud.google.com/recaptcha-enterprise/docs/interpret-assessment + return data.riskAnalysis; + } +} diff --git a/apps/api/src/app/services/RewardCoinService.ts b/apps/api/src/app/services/RewardCoinService.ts new file mode 100644 index 000000000..6e0bb6e2e --- /dev/null +++ b/apps/api/src/app/services/RewardCoinService.ts @@ -0,0 +1,140 @@ +import { + ERC20, + ERC20Document, + RewardCoin, + RewardCoinDocument, + Transaction, + WalletDocument, +} from '@thxnetwork/api/models'; +import { RewardCoinPayment } from '@thxnetwork/api/models'; +import { IRewardService } from './interfaces/IRewardService'; +import { ChainId, ERC20Type, TransactionState } from '@thxnetwork/common/enums'; +import { BigNumber } from 'ethers'; +import AccountProxy from '../proxies/AccountProxy'; +import ERC20Service from './ERC20Service'; +import MailService from './MailService'; +import PoolService from './PoolService'; +import { toWei } from 'web3-utils'; + +export default class RewardCoinService implements IRewardService { + models = { + reward: RewardCoin, + payment: RewardCoinPayment, + }; + + async decorate({ reward }) { + const erc20 = await ERC20.findById(reward.erc20Id); + return { ...reward.toJSON(), erc20 }; + } + + async decoratePayment(payment: TBaseRewardPayment) { + return payment; + } + + findById(id: string) { + return this.models.reward.findById(id); + } + + async create(data: Partial) { + const erc20 = await this.getERC20(data.erc20Id); + await this.addMinter(erc20, data.poolId); + + return await this.models.reward.create(data); + } + + async update(reward: RewardCoinDocument, updates: Partial) { + const erc20 = await this.getERC20(updates.erc20Id); + await this.addMinter(erc20, reward.poolId); + + return await this.models.reward.findByIdAndUpdate(reward._id, updates, { new: true }); + } + + async remove(reward: RewardCoinDocument) { + await this.models.reward.findOneAndDelete(reward._id); + } + + async createPayment({ + reward, + safe, + wallet, + }: { + reward: TRewardCoin; + safe: WalletDocument; + wallet?: WalletDocument; + }) { + if (!wallet) return { result: false, reason: 'Wallet not found' }; + + const erc20 = await ERC20.findById(reward.erc20Id); + if (!erc20) return { result: false, reason: 'ERC20 not found' }; + + // TODO Wei should be determined in the FE + const amount = toWei(reward.amount as string); + + // Transfer ERC20 from safe to wallet + await ERC20Service.transferFrom(erc20, safe, wallet.address, amount); + + // Register the payment + await RewardCoinPayment.create({ + rewardId: reward._id, + sub: wallet.sub, + walletId: wallet._id, + poolId: reward.poolId, + amount: reward.pointPrice, + }); + } + + async getValidationResult({ reward, safe }: { reward: RewardCoinDocument; safe: WalletDocument }) { + const erc20 = await ERC20.findById(reward.erc20Id); + if (!erc20) throw new Error('ERC20 not found'); + + // Check if there are pending transactions that are not mined or failed. + const txs = await Transaction.find({ + walletId: safe.id, + $or: [ + { state: TransactionState.Confirmed }, + { state: TransactionState.Sent }, + { state: TransactionState.Queued }, + ], + }).sort({ createdAt: 'asc' }); + if (txs.length) { + return { result: false, reason: `Found ${txs.length} pending transactions, please try again later.` }; + } + + // Check balances + const balanceOfPool = await erc20.contract.methods.balanceOf(safe.address).call(); + const isTransferable = [ERC20Type.Unknown, ERC20Type.Limited].includes(erc20.type); + const isBalanceInsufficient = BigNumber.from(balanceOfPool).lt(BigNumber.from(toWei(reward.amount))); + + // Notifiy the campaign owner if token is transferrable and balance is insufficient + if (isTransferable && isBalanceInsufficient) { + const owner = await AccountProxy.findById(safe.sub); + const html = `Not enough ${erc20.symbol} available in campaign contract ${safe.address}. Please top up on ${ + ChainId[erc20.chainId] + }`; + + // Send email to campaign owner + await MailService.send(owner.email, `⚠️ Out of ${erc20.symbol}!"`, html); + + return { + result: false, + reason: `We have notified the campaign owner that there is insufficient ${erc20.symbol} in the campaign wallet. Please try again later!`, + }; + } + + return { result: true, reason: '' }; + } + + private getERC20(erc20Id: TERC20) { + return ERC20.findById(erc20Id); + } + + private async addMinter(erc20: ERC20Document, poolId: string) { + if (erc20.type !== ERC20Type.Unlimited) return; + + const { safe } = await PoolService.getById(poolId); + const isMinter = await ERC20Service.isMinter(erc20, safe.address); + if (!isMinter) { + await ERC20Service.addMinter(erc20, safe.address); + } + } +} diff --git a/apps/api/src/app/services/RewardCouponService.ts b/apps/api/src/app/services/RewardCouponService.ts new file mode 100644 index 000000000..403b70151 --- /dev/null +++ b/apps/api/src/app/services/RewardCouponService.ts @@ -0,0 +1,75 @@ +import { CouponCode, RewardCoupon, RewardCouponPayment } from '../models'; +import { IRewardService } from './interfaces/IRewardService'; + +export default class RewardCouponService implements IRewardService { + models = { + reward: RewardCoupon, + payment: RewardCouponPayment, + }; + + async decorate({ reward }) { + const couponCodes = await CouponCode.find({ couponRewardId: reward._id }); + const progress = { + count: await this.models.payment.countDocuments({ + rewardId: reward._id, + }), + limit: couponCodes.length, + }; + + return { ...reward.toJSON(), progress, limit: couponCodes.length }; + } + + async decoratePayment(payment: TRewardPayment) { + const code = await CouponCode.findById(payment.couponCodeId); + return { ...payment.toJSON(), code: code && code.code }; + } + + async getValidationResult({ reward }: { reward: TReward; account?: TAccount }) { + const couponCode = await CouponCode.findOne({ couponRewardId: String(reward._id), sub: { $exists: false } }); + if (!couponCode) return { result: false, reason: 'No more coupon codes available' }; + + return { result: true, reason: '' }; + } + + async create(data: Partial) { + const reward = await this.models.reward.create(data); + await this.createCouponCodes(reward, data.codes); + return reward; + } + + private async createCouponCodes(reward: TRewardCoupon, codes: string[]) { + await Promise.all( + codes.map(async (code: string) => await CouponCode.create({ code, couponRewardId: reward._id })), + ); + } + + async update(reward: TReward, updates: Partial): Promise { + await this.createCouponCodes(reward, updates.codes); + return this.models.reward.findByIdAndUpdate(reward, updates, { new: true }); + } + + remove(reward: TReward): Promise { + return this.models.reward.findByIdAndDelete(reward._id); + } + + findById(id: string): Promise { + return this.models.reward.findById(id); + } + + async createPayment({ reward, account }: { reward: TRewardNFT; account: TAccount }) { + const couponCode = await CouponCode.findOne({ couponRewardId: reward._id, sub: { $exists: false } }); + if (!couponCode) return { result: false, reason: 'No more coupon codes available' }; + + // Change owner of couponCode + await couponCode.updateOne({ sub: account.sub }); + + // Register payment + await this.models.payment.create({ + couponCodeId: couponCode._id, + rewardId: reward.id, + sub: account.sub, + poolId: reward.poolId, + amount: reward.pointPrice, + }); + } +} diff --git a/apps/api/src/app/services/RewardCustomService.ts b/apps/api/src/app/services/RewardCustomService.ts new file mode 100644 index 000000000..5937849e2 --- /dev/null +++ b/apps/api/src/app/services/RewardCustomService.ts @@ -0,0 +1,68 @@ +import { Identity, RewardCustom, RewardCustomPayment, Webhook } from '../models'; +import { IRewardService } from './interfaces/IRewardService'; +import { Event } from '@thxnetwork/common/enums'; +import WebhookService from './WebhookService'; + +export default class RewardCustomService implements IRewardService { + models = { + reward: RewardCustom, + payment: RewardCustomPayment, + }; + + async decorate({ reward, account }) { + const identities = account ? await Identity.find({ poolId: reward.poolId, sub: account.sub }) : []; + return { ...reward.toJSON(), isDisabled: !identities.length }; + } + + async decoratePayment(payment: TRewardPayment): Promise { + return payment; + } + + async getValidationResult({ reward, account }: { reward: TReward; account?: TAccount }) { + const identities = account ? await Identity.find({ poolId: reward.poolId, sub: account.sub }) : []; + if (!identities.length) return { result: false, reason: 'No identity connected for this campaign.' }; + + return { result: true, reason: '' }; + } + + create(data: Partial) { + return this.models.reward.create(data); + } + + update(reward: TReward, updates: Partial): Promise { + return this.models.reward.findByIdAndUpdate(reward._id, updates, { new: true }); + } + + remove(reward: TReward): Promise { + return this.models.reward.findByIdAndDelete(reward._id); + } + + findById(id: string): Promise { + return this.models.reward.findById(id); + } + + async createPayment({ + reward, + account, + }: { + reward: TReward; + account: TAccount; + }): Promise { + const webhook = await Webhook.findById(reward.webhookId); + if (!webhook) return { result: false, reason: 'Webhook not found.' }; + + // Call the webhook with known account identities for this campaign and optional metadata + await WebhookService.requestAsync(webhook, account.sub, { + type: Event.RewardCustomPayment, + data: { customRewardId: reward._id, metadata: reward.metadata }, + }); + + // Register the payment + await this.models.payment.create({ + rewardId: reward.id, + poolId: reward.poolId, + sub: account.sub, + amount: reward.pointPrice, + }); + } +} diff --git a/apps/api/src/app/services/RewardDiscordRoleService.ts b/apps/api/src/app/services/RewardDiscordRoleService.ts new file mode 100644 index 000000000..1fe5297b7 --- /dev/null +++ b/apps/api/src/app/services/RewardDiscordRoleService.ts @@ -0,0 +1,95 @@ +import { AccessTokenKind } from '@thxnetwork/common/enums'; +import { RewardDiscordRole, RewardDiscordRolePayment } from '../models'; +import { IRewardService } from './interfaces/IRewardService'; +import { discordColorToHex } from '../util/discord'; +import DiscordService from './DiscordService'; + +export default class RewardDiscordRoleService implements IRewardService { + models = { + reward: RewardDiscordRole, + payment: RewardDiscordRolePayment, + }; + + async decorate({ reward, account }) { + const token = account && account.tokens.find(({ kind }) => kind === AccessTokenKind.Discord); + return { ...reward.toJSON(), isDisabled: !token }; + } + + async decoratePayment(payment: TRewardPayment): Promise { + const reward = await this.models.reward.findById(payment.rewardId); + const guild = reward && (await DiscordService.getGuild(reward.poolId)); + const role = guild && reward && (await DiscordService.getRole(guild.id, reward.discordRoleId)); + const discordServerURL = guild && `https://discordapp.com/channels/${guild.id}/`; + + return { + ...payment.toJSON(), + discordServerURL, + guild: guild && { + name: guild.name, + icon: guild.icon && `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`, + }, + role: role && { + name: role.name, + color: discordColorToHex(role.color), + }, + }; + } + + async getValidationResult({ reward, account }: { reward: TReward; account?: TAccount }) { + const token = this.getToken(account); + if (!token) { + return { result: false, reason: 'Your account is not connected to a Discord account' }; + } + + const guild = await DiscordService.getGuild(reward.poolId); + if (!guild) { + return { result: false, reason: `THX Bot is not invited to the ${guild.name} Discord server` }; + } + + const member = await DiscordService.getMember(guild.id, token.userId); + if (!member) { + return { result: false, reason: `You are not a member of the ${guild.name} Discord server` }; + } + + return { result: true, reason: '' }; + } + + create(data: Partial) { + return this.models.reward.create(data); + } + + update(reward: TReward, updates: Partial): Promise { + return this.models.reward.findByIdAndUpdate(reward, updates, { new: true }); + } + + remove(reward: TReward): Promise { + return this.models.reward.findByIdAndDelete(reward._id); + } + + findById(id: string) { + return this.models.reward.findById(id); + } + + async createPayment({ reward, account }: { reward: TRewardNFT; account: TAccount }) { + const token = this.getToken(account); + const guild = await DiscordService.getGuild(reward.poolId); + const role = await DiscordService.getRole(guild.id, reward.discordRoleId); + const member = await DiscordService.getMember(guild.id, token.userId); + + // Add role to discord user + await member.roles.add(role); + + // Register the payment + await this.models.payment.create({ + rewardId: reward._id, + discordRoleId: reward.discordRoleId, + sub: account.sub, + poolId: reward.poolId, + amount: reward.pointPrice, + }); + } + + private getToken(account: TAccount) { + return account.tokens.find(({ kind }) => kind === AccessTokenKind.Discord); + } +} diff --git a/apps/api/src/app/services/RewardGalachainService.ts b/apps/api/src/app/services/RewardGalachainService.ts new file mode 100644 index 000000000..11df2f49e --- /dev/null +++ b/apps/api/src/app/services/RewardGalachainService.ts @@ -0,0 +1,156 @@ +import { RewardGalachain, RewardGalachainPayment, WalletDocument } from '../models'; +import GalachainService from './GalachainService'; +import { IRewardService } from './interfaces/IRewardService'; +import { BigNumber } from 'bignumber.js'; +import PoolService from './PoolService'; + +export default class RewardGalachainService implements IRewardService { + models = { + reward: RewardGalachain, + payment: RewardGalachainPayment, + }; + + async decorate({ reward }: { reward: TRewardGalachain; account?: TAccount }): Promise { + const contract = { + channelName: reward.contractChannelName, + chaincodeName: reward.contractChaincodeName, + contractName: reward.contractContractName, + }; + const token = { + collection: reward.tokenCollection, + category: reward.tokenCategory, + type: reward.tokenType, + additionalKey: reward.tokenAdditionalKey, + instance: new BigNumber(0), + }; + const pool = await PoolService.getById(reward.poolId); + const [balance] = (await GalachainService.balanceOf( + contract, + token, + pool.settings.galachainPrivateKey, + )) as any[]; + const paymentCount = await this.models.payment.countDocuments({ + rewardId: reward._id, + }); + const progress = { + count: paymentCount, + limit: Number(balance.quantity) + paymentCount, + }; + + return { ...reward.toJSON(), progress, limit: balance.quantity }; + } + + async decoratePayment(payment: TRewardPayment): Promise { + const reward = await this.models.reward.findById(payment.rewardId); + return { reward, ...payment.toJSON() }; + } + + async getValidationResult({ reward }: { reward: any; account?: TAccount }): Promise { + const { tokenCollection, tokenCategory, tokenType, tokenAdditionalKey } = reward; + const token = { + collection: tokenCollection, + category: tokenCategory, + type: tokenType, + additionalKey: tokenAdditionalKey, + instance: new BigNumber(0), + }; + const contract = { + channelName: reward.contractChannelName, + chaincodeName: reward.contractChaincodeName, + contractName: reward.contractContractName, + methodName: 'TransferToken', + }; + const pool = await PoolService.getById(reward.poolId); + + // Check balance of the distributor + const [balance] = (await GalachainService.balanceOf( + contract, + token, + pool.settings.galachainPrivateKey, + )) as any[]; + + if (Number(balance.quantity) < reward.amount) { + return { result: false, reason: 'Distributor has an insufficient balance' }; + } + + return { result: true, reason: '' }; + } + + create(data: any): Promise { + return this.models.reward.create(data); + } + + update(reward: TReward, updates: Partial): Promise { + return this.models.reward.findByIdAndUpdate(reward, updates, { new: true }); + } + + remove(reward: TReward): Promise { + return this.models.reward.findByIdAndDelete(reward._id); + } + + findById(id: string) { + return this.models.reward.findById(id); + } + + async createPayment({ + reward, + wallet, + }: { + reward: TRewardGalachain; + account: TAccount; + safe: WalletDocument; + wallet?: WalletDocument; + }): Promise { + const token = this.getToken(reward); + const contract = this.getContract(reward); + const pool = await PoolService.getById(reward.poolId); + + // Get first token from balance + const instanceId = await this.getInstance(contract, token, pool); + + // Transfer token to user wallet + await GalachainService.transfer( + contract, + token, + wallet.address, + Number(reward.amount), + instanceId, + pool.settings.galachainPrivateKey, + ); + + // Register payment + await this.models.payment.create({ + sub: wallet.sub, + walletId: wallet._id, + rewardId: reward._id, + amount: reward.amount, + }); + } + + private async getInstance(contract: TGalachainContract, token: TGalachainToken, pool: TPool) { + const [balance] = (await GalachainService.balanceOf( + contract, + token, + pool.settings.galachainPrivateKey, + )) as any[]; + const [instanceId] = balance.instanceIds; + return instanceId; + } + + private getToken(reward: TRewardGalachain) { + return { + collection: reward.tokenCollection, + category: reward.tokenCategory, + type: reward.tokenType, + additionalKey: reward.tokenAdditionalKey, + }; + } + + private getContract(reward: TRewardGalachain) { + return { + channelName: reward.contractChannelName, + chaincodeName: reward.contractChaincodeName, + contractName: reward.contractContractName, + }; + } +} diff --git a/apps/api/src/app/services/RewardNFTService.ts b/apps/api/src/app/services/RewardNFTService.ts new file mode 100644 index 000000000..8b6ac1838 --- /dev/null +++ b/apps/api/src/app/services/RewardNFTService.ts @@ -0,0 +1,180 @@ +import { + ERC1155MetadataDocument, + ERC1155TokenDocument, + ERC721MetadataDocument, + ERC721TokenDocument, + RewardNFT, + RewardNFTDocument, + RewardNFTPayment, + WalletDocument, +} from '@thxnetwork/api/models'; +import { NFTVariant } from '@thxnetwork/common/enums'; +import { IRewardService } from './interfaces/IRewardService'; +import ERC721Service from './ERC721Service'; +import ERC1155Service from './ERC1155Service'; +import PoolService from './PoolService'; +import SafeService from './SafeService'; + +export default class RewardNFTService implements IRewardService { + models = { + reward: RewardNFT, + payment: RewardNFTPayment, + }; + services = { + [NFTVariant.ERC721]: ERC721Service, + [NFTVariant.ERC1155]: ERC1155Service, + }; + + async decorate({ reward, account }: { reward: TRewardNFT; account?: TAccount }) { + const nft = await this.findNFT(reward); + const token = reward.tokenId && (await this.findTokenById(nft, reward.tokenId)); + const metadataId = token ? token.metadataId : reward.metadataId ? reward.metadataId : null; + const metadata = metadataId ? await this.findMetadataById(nft, metadataId) : null; + const expiry = reward.expiryDate && { + date: reward.expiryDate, + now: new Date(), + }; + return { ...reward.toJSON(), chainId: nft.chainId, nft, metadata, token, expiry }; + } + + async decoratePayment(payment: TRewardPayment): Promise { + return payment; + } + + async getValidationResult({ + reward, + safe, + wallet, + }: { + reward: TRewardNFT; + safe?: WalletDocument; + wallet?: WalletDocument; + account?: TAccount; + }) { + const nft = await this.findNFT(reward); + if (!nft) return { result: false, reason: 'NFT contract is no longer available' }; + + // This will require a transfer + if (reward.tokenId) { + // Check if Safe is the owner + const { contract } = nft; + const token = await this.findTokenById(nft, reward.tokenId); + if (!token) return { result: false, reason: 'Token not found' }; + + const owner = await contract.methods.ownerOf(token.tokenId).call(); + if (owner.toLowerCase() !== safe.address.toLowerCase()) { + return { result: false, reason: 'Token is no longer owner by campaign Safe.' }; + } + } + + // Will require a mint + if (reward.metadataId) { + const isMinter = await this.services[nft.variant].isMinter(nft, safe.address); + if (!isMinter) return { result: false, reason: 'Campaign Safe is not a minter of the NFT contract.' }; + } + + // Check receiving wallet for chain compatibility + if (wallet.chainId !== nft.chainId) { + return { result: false, reason: 'Your wallet is not on the same chain as the NFT contract.' }; + } + + return { result: true, reason: '' }; + } + + async create(data: Partial) { + // If erc721Id or erc1155Id, check if campaign safe is minter + if (data.metadataId) { + const pool = await PoolService.getById(data.poolId); + const safe = await SafeService.findOneByPool(pool, pool.chainId); + await this.addMinter(data, safe.address); + } + + return await this.models.reward.create(data); + } + + update(reward: TRewardNFT, updates: Partial) { + return this.models.reward.findByIdAndUpdate(reward._id, updates, { new: true }); + } + + async remove(reward: RewardNFTDocument) { + await this.models.reward.findOneAndDelete(reward._id); + } + + async createPayment({ + reward, + safe, + wallet, + }: { + reward: RewardNFTDocument; + safe: WalletDocument; + wallet?: WalletDocument; + }) { + const erc1155Amount = reward.erc1155Amount && String(reward.erc1155Amount); + const nft = await this.findNFT(reward); + if (!nft) throw new Error('NFT not found'); + + // Get token and metadata for either ERC721 or ERC1155 based contracts + // and mint if metadataId is present or transfer if tokenId is present + let token: ERC721TokenDocument | ERC1155TokenDocument, + metadata: ERC721MetadataDocument | ERC1155MetadataDocument; + + // Mint a token if metadataId is present + if (reward.metadataId) { + metadata = await this.findMetadataById(nft, reward.metadataId); + + // Mint the token to wallet address + token = await this.services[nft.variant].mint(safe, nft, wallet, metadata, erc1155Amount); + } + + // Transfer a token if tokenId is present + if (reward.tokenId) { + token = await this.findTokenById(nft, reward.tokenId); + metadata = await this.findMetadataByToken(nft, token); + + // Transfer the token from safe to wallet address + token = await this.services[nft.variant].transferFrom(nft, safe, wallet.address, token, erc1155Amount); + } + + // Register the payment + await RewardNFTPayment.create({ + rewardId: reward._id, + sub: wallet.sub, + walletId: wallet._id, + poolId: reward.poolId, + amount: reward.pointPrice, + }); + } + + findById(id: string) { + return this.models.reward.findById(id); + } + + findMetadataByToken(nft: TERC721 | TERC1155, token: TERC721Token | TERC1155Token) { + return this.services[nft.variant].findMetadataByToken(token); + } + + findTokenById(nft: TERC721 | TERC1155, tokenId: string) { + console.log(nft.variant, tokenId); + return this.services[nft.variant].findTokenById(tokenId); + } + + findMetadataById(nft: TERC721 | TERC1155, metadataId: string) { + return this.services[nft.variant].findMetadataById(metadataId); + } + + findNFT({ erc721Id, erc1155Id }: { erc721Id?: string; erc1155Id?: string }) { + if (erc721Id) { + return ERC721Service.findById(erc721Id); + } + + if (erc1155Id) { + return ERC1155Service.findById(erc1155Id); + } + } + + private async addMinter({ erc721Id, erc1155Id }: { erc721Id?: string; erc1155Id?: string }, address: string) { + const nft = await this.findNFT({ erc721Id, erc1155Id }); + const isMinter = await this.services[nft.variant].isMinter(nft, address); + if (!isMinter) await this.services[nft.variant].addMinter(nft, address); + } +} diff --git a/apps/api/src/app/services/RewardService.ts b/apps/api/src/app/services/RewardService.ts new file mode 100644 index 000000000..cdafab5d2 --- /dev/null +++ b/apps/api/src/app/services/RewardService.ts @@ -0,0 +1,292 @@ +import { Document } from 'mongoose'; +import { RewardVariant } from '@thxnetwork/common/enums'; +import { Participant, QRCodeEntry, WalletDocument } from '@thxnetwork/api/models'; +import { v4 } from 'uuid'; +import { logger } from '../util/logger'; +import { Job } from '@hokify/agenda'; +import RewardCoinService from './RewardCoinService'; +import LockService from './LockService'; +import AccountProxy from '../proxies/AccountProxy'; +import ParticipantService from './ParticipantService'; +import RewardNFTService from './RewardNFTService'; +import RewardCouponService from './RewardCouponService'; +import ImageService from './ImageService'; +import PointBalanceService from './PointBalanceService'; +import MailService from './MailService'; +import RewardDiscordRoleService from './RewardDiscordRoleService'; +import RewardCustomService from './RewardCustomService'; +import RewardGalachainService from './RewardGalachainService'; +import PoolService from './PoolService'; +import WalletService from './WalletService'; +import THXService from './THXService'; + +const serviceMap = { + [RewardVariant.Coin]: new RewardCoinService(), + [RewardVariant.NFT]: new RewardNFTService(), + [RewardVariant.Custom]: new RewardCustomService(), + [RewardVariant.Coupon]: new RewardCouponService(), + [RewardVariant.DiscordRole]: new RewardDiscordRoleService(), + [RewardVariant.Galachain]: new RewardGalachainService(), +}; + +export default class RewardService { + static async count({ poolId }) { + const variants = Object.keys(RewardVariant).filter((v) => !isNaN(Number(v))); + const counts = await Promise.all( + variants.map(async (variant: string) => { + const Reward = serviceMap[variant].models.reward; + return await Reward.countDocuments({ poolId, isPublished: true }); + }), + ); + return counts.reduce((acc, count) => acc + count, 0); + } + + static async list({ pool, account }) { + const rewardVariants = Object.keys(RewardVariant).filter((v) => !isNaN(Number(v))); + const callback: any = async (variant: RewardVariant) => { + const Reward = serviceMap[variant].models.reward; + // Filter out rewards that have QR codes (RDM) + const qrCodeRewardIds = await QRCodeEntry.find().distinct('rewardId'); + const rewards = await Reward.find({ + _id: { $nin: qrCodeRewardIds }, + poolId: pool._id, + variant, + isPublished: true, + $or: [ + // Include quests with expiryDate less than or equal to now + { expiryDate: { $exists: true, $gte: new Date() } }, + // Include quests with no expiryDate + { expiryDate: { $exists: false } }, + ], + }); + return await Promise.all( + rewards.map(async (reward) => { + try { + const decorated = await serviceMap[reward.variant].decorate({ reward, account }); + const isLocked = await this.isLocked({ reward, account }); + const isStocked = await this.isStocked(reward); + const isExpired = this.isExpired(reward); + const isAvailable = await this.isAvailable({ reward, account }); + const progress = { + count: await serviceMap[reward.variant].models.payment.countDocuments({ + rewardId: reward._id, + }), + limit: reward.limit, + }; + + // Decorated properties may override generic properties + return { progress, isLocked, isStocked, isExpired, isAvailable, ...decorated }; + } catch (error) { + logger.error(error); + } + }), + ); + }; + + return await Promise.all(rewardVariants.map(callback)); + } + + static async findPaymentsBySub( + reward: TReward, + { skip, limit, query }: { skip: number; limit: number; query: string }, + ) { + const Payment = serviceMap[reward.variant].models.payment; + // Get all matching accounts by email and username first + const accounts = await AccountProxy.find({ query }); + // We then fetch the payments for the list of subs + const subs = accounts.map(({ sub }) => sub); + // Then we fetch the participants for the poolId and the list of subs + const participants = await Participant.find({ poolId: reward.poolId, sub: { $in: subs } }); + const payments = await Payment.find({ rewardId: reward._id, sub: { $in: subs } }) + .limit(limit) + .skip(skip); + + return { payments, accounts, participants }; + } + + static async findPaymentsByReward( + reward: TReward, + { skip, limit }: { skip: number; limit: number; query: string }, + ) { + const Payment = serviceMap[reward.variant].models.payment; + // If there is no query we fetch the payments for the reward + const payments = await Payment.find({ rewardId: reward._id }).limit(limit).skip(skip); + const subs = payments.map(({ sub }) => sub); + const accounts = await AccountProxy.find({ subs }); + const participants = await Participant.find({ poolId: reward.poolId, sub: { $in: subs } }); + + return { payments, accounts, participants }; + } + + static async findPayments(reward: TReward, { page, limit, query }: { page: number; limit: number; query: string }) { + const skip = (page - 1) * limit; + const Payment = serviceMap[reward.variant].models.payment; + const total = await Payment.countDocuments({ rewardId: reward._id }); + + // If there is a query we fetch accounts by username first + const { payments, accounts, participants } = + query.length > 3 + ? await this.findPaymentsBySub(reward, { skip, limit, query }) + : await this.findPaymentsByReward(reward, { skip, limit, query }); + const promises = payments.map(async (payment: Document & TRewardPayment) => + ParticipantService.decorate(payment, { accounts, participants }), + ); + const results = await Promise.allSettled(promises); + + return { + total, + limit, + page, + results: results.filter((result) => result.status === 'fulfilled').map((result: any) => result.value), + }; + } + + static async findPaymentsForSub(sub: string) { + const rewardVariants: string[] = Object.keys(RewardVariant).filter((v) => !isNaN(Number(v))); + const payments = await Promise.allSettled( + rewardVariants.map(async (variant: string) => { + const rewardVariant = Number(variant); + const payments = await serviceMap[rewardVariant].models.payment.find({ sub }); + const callback = payments.map(async (p: Document & TRewardPayment) => { + const decorated = await serviceMap[rewardVariant].decoratePayment(p); + return { ...decorated, rewardVariant }; + }); + return await Promise.all(callback); + }), + ); + + return payments + .filter((result) => result.status === 'fulfilled') + .map((result: any) => result.value) + .flat(); + } + + static async createPaymentJob(job: Job) { + try { + const { variant, sub, rewardId, walletId } = job.attrs.data as any; + const account = await AccountProxy.findById(sub); + const reward = await this.findById(variant, rewardId); + const pool = await PoolService.getById(reward.poolId); + const wallet = walletId && (await WalletService.findById(walletId)); + + // Validate supply, expiry, locked and reward specific validation + const validationResult = await this.getValidationResult({ reward, account, safe: pool.safe }); + if (!validationResult.result) return validationResult.reason; + + // Subtract points for account + await PointBalanceService.subtract(pool, account, reward.pointPrice); + + // Send email notification + let html = `

Congratulations!🚀

`; + html += `

Your payment has been received! ${reward.title} is available in your account.

`; + html += `

View Wallet

`; + await MailService.send(account.email, `🎁 Reward Received!`, html); + + const payment = await serviceMap[variant].createPayment({ reward, account, safe: pool.safe, wallet }); + + // Register THX onboarding campaign event + await THXService.createEvent(account, 'reward_payment_created'); + + // Register the payment for the account + return payment; + } catch (error) { + console.log(error); + logger.error(error); + } + } + + static async create(variant: RewardVariant, poolId: string, data: Partial, file?: Express.Multer.File) { + if (file) { + data.image = await ImageService.upload(file); + } + + const reward = await serviceMap[variant].create({ ...data, poolId, variant, uuid: v4() }); + + // TODO Implement publish notification flow for rewards + // if (data.isPublished) { + // await NotificationService.notify(variant, quest); + // } + + return reward; + } + + static async update(reward: TReward, updates: Partial, file?: Express.Multer.File) { + if (file) { + updates.image = await ImageService.upload(file); + } + + reward = await serviceMap[reward.variant].update(reward, updates); + + // TODO Implement publish notification flow for rewards + // if (data.isPublished) { + // await NotificationService.notify(variant, quest); + // } + + return reward; + } + + static async remove(reward: TReward) { + return await serviceMap[reward.variant].remove(reward); + } + + static findById(variant: RewardVariant, rewardId: string) { + return serviceMap[variant].findById(rewardId); + } + + static async getValidationResult({ + reward, + account, + safe, + wallet, + }: { + reward: TReward; + account?: TAccount; + safe?: WalletDocument; + wallet?: WalletDocument; + }) { + const participant = await Participant.findOne({ sub: account.sub, poolId: reward.poolId }); + if (Number(participant.balance) < Number(reward.pointPrice)) { + return { result: false, reason: 'Participant has insufficient points.' }; + } + + const isLocked = await this.isLocked({ reward, account }); + if (isLocked) return { result: false, reason: 'This reward is locked.' }; + + const isExpired = this.isExpired(reward); + if (isExpired) return { result: false, reason: 'This reward claim has expired.' }; + + const isStocked = await this.isStocked(reward); + if (!isStocked) return { result: false, reason: 'This reward is out of stock.' }; + + return serviceMap[reward.variant].getValidationResult({ reward, account, wallet, safe }); + } + + static async isLocked({ reward, account }) { + if (!account || !reward.locks.length) return false; + return await LockService.getIsLocked(reward.locks, account); + } + + static isExpired(reward: TReward) { + if (!reward.expiryDate) return false; + return Date.now() > new Date(reward.expiryDate).getTime(); + } + + static async isStocked(reward) { + if (!reward.limit) return true; + // Check if reward has a limit and if limit has been reached + const amountOfPayments = await serviceMap[reward.variant].models.payment.countDocuments({ + rewardId: reward._id, + }); + return amountOfPayments < reward.limit; + } + + static async isAvailable({ reward, account }: { reward: TReward; account?: TAccount }) { + if (!account) return true; + + const isLocked = await this.isLocked({ reward, account }); + const isStocked = await this.isStocked(reward); + const isExpired = this.isExpired(reward); + + return !isLocked && !isExpired && isStocked; + } +} diff --git a/apps/api/src/app/services/SafeService.ts b/apps/api/src/app/services/SafeService.ts new file mode 100644 index 000000000..406769657 --- /dev/null +++ b/apps/api/src/app/services/SafeService.ts @@ -0,0 +1,249 @@ +import { Wallet, WalletDocument, Pool, PoolDocument, Transaction } from '@thxnetwork/api/models'; +import { ChainId, WalletVariant } from '@thxnetwork/common/enums'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { contractNetworks } from '@thxnetwork/api/contracts'; +import { getChainId, safeVersion } from '@thxnetwork/api/services/ContractService'; +import { toChecksumAddress } from 'web3-utils'; +import Safe, { SafeAccountConfig, SafeFactory } from '@safe-global/protocol-kit'; +import SafeApiKit from '@safe-global/api-kit'; +import { + SafeMultisigTransactionResponse, + SafeTransactionDataPartial, + SafeVersion, +} from '@safe-global/safe-core-sdk-types'; +import { logger } from '@thxnetwork/api/util/logger'; +import { agenda, JobType } from '@thxnetwork/api/util/agenda'; +import { Job } from '@hokify/agenda'; +import { convertObjectIdToNumber } from '../util'; +import TransactionService from './TransactionService'; + +function getSafeSDK(chainId: ChainId) { + const { txServiceUrl, ethAdapter } = getProvider(chainId); + return new SafeApiKit({ txServiceUrl, ethAdapter }); +} + +function reset(wallet: WalletDocument, userWalletAddress: string) { + const { defaultAccount } = getProvider(wallet.chainId); + return deploy(wallet, [toChecksumAddress(defaultAccount), toChecksumAddress(userWalletAddress)]); +} + +async function create( + data: { chainId: ChainId; sub: string; safeVersion?: SafeVersion; address?: string; poolId?: string }, + userWalletAddress?: string, +) { + const { safeVersion, chainId, sub, address, poolId } = data; + const { defaultAccount } = getProvider(chainId); + const wallet = await Wallet.create({ variant: WalletVariant.Safe, sub, chainId, address, safeVersion, poolId }); + + // Concerns a Metamask account so we do not deploy and return early + if (!safeVersion && address) return wallet; + + // Add relayer address and consider this a campaign safe + const owners = [toChecksumAddress(defaultAccount)]; + // Add user address as a signer and consider this a participant safe + if (userWalletAddress) owners.push(toChecksumAddress(userWalletAddress)); + + // If campaign safe we provide a nonce based on the timestamp in the MongoID the pool (poolId value) + const nonce = wallet.poolId && String(convertObjectIdToNumber(wallet.poolId)); + + return await deploy(wallet, owners, nonce); +} + +async function deploy(wallet: WalletDocument, owners: string[], nonce?: string) { + const { ethAdapter } = getProvider(wallet.chainId); + const safeFactory = await SafeFactory.create({ + safeVersion: wallet.safeVersion as SafeVersion, + ethAdapter, + contractNetworks, + }); + const safeAccountConfig: SafeAccountConfig = { + owners, + threshold: owners.length, + }; + const safeAddress = toChecksumAddress(await safeFactory.predictSafeAddress(safeAccountConfig, nonce)); + + try { + await Safe.create({ + ethAdapter, + safeAddress, + contractNetworks, + }); + } catch (error) { + await agenda.now(JobType.DeploySafe, { + safeAccountConfig, + safeVersion: wallet.safeVersion, + safeAddress, + safeWalletId: String(wallet._id), + }); + } + + return await Wallet.findByIdAndUpdate(wallet._id, { address: safeAddress }, { new: true }); +} + +async function createJob(job: Job) { + const { safeAccountConfig, safeVersion, safeAddress, safeWalletId } = job.attrs.data as any; + if (!safeAccountConfig || !safeVersion || !safeAddress || !safeWalletId) return; + + const wallet = await Wallet.findById(safeWalletId); + const { ethAdapter } = getProvider(wallet.chainId); + const safeFactory = await SafeFactory.create({ + safeVersion, + ethAdapter, + contractNetworks, + }); + + // If campaign safe we provide a nonce based on the timestamp in the MongoID the pool (poolId value) + const nonce = wallet.poolId && String(convertObjectIdToNumber(wallet.poolId)); + const config = { safeAccountConfig, options: { gasLimit: '3000000' } }; + if (nonce) config['saltNonce'] = nonce; + + await safeFactory.deploySafe(config); + logger.debug(`[${wallet.sub}] Deployed Safe: ${safeAddress}`); + + // Set safeAddress for campaign to keep address available for potential regression + if (wallet.poolId) { + await Pool.findByIdAndUpdate(wallet.poolId, { safeAddress: toChecksumAddress(safeAddress) }); + } +} + +function findById(id: string) { + return Wallet.findById(id); +} + +function findOne(query) { + return Wallet.findOne({ ...query, variant: WalletVariant.Safe, poolId: { $exists: false } }); +} + +function findOneByAddress(address: string) { + return Wallet.findOne({ address: toChecksumAddress(address) }); +} + +async function findOneByPool(pool: PoolDocument, chainId?: ChainId) { + return await Wallet.findOne({ + poolId: pool.id, + chainId: chainId || getChainId(), + sub: pool.sub, + safeVersion, + }); +} + +async function getOwners(wallet: WalletDocument) { + const { ethAdapter } = getProvider(wallet.chainId); + const safeSdk = await Safe.create({ + ethAdapter, + safeAddress: wallet.address, + contractNetworks, + }); + + return await safeSdk.getOwners(); +} + +async function createSwapOwnerTransaction(wallet: WalletDocument, oldOwnerAddress: string, newOwnerAddress: string) { + const { ethAdapter } = getProvider(wallet.chainId); + const safeSdk = await Safe.create({ + ethAdapter, + safeAddress: wallet.address, + contractNetworks, + }); + + return await safeSdk.createSwapOwnerTx({ oldOwnerAddress, newOwnerAddress }); +} + +async function proposeTransaction(wallet: WalletDocument, safeTransactionData: SafeTransactionDataPartial) { + const { ethAdapter, signer } = getProvider(wallet.chainId); + const safeSdk = await Safe.create({ + ethAdapter, + safeAddress: wallet.address, + contractNetworks, + }); + + // Get nonce for this Safes transaction + const nonce = await safeSdk.getNonce(); + const safeTransaction = await safeSdk.createTransaction({ safeTransactionData, options: { nonce: nonce + 1 } }); + + // Create hash for this transaction + const safeTxHash = await safeSdk.getTransactionHash(safeTransaction); + const senderSignature = await safeSdk.signTransactionHash(safeTxHash); + const safeAPIKit = getSafeSDK(wallet.chainId); + + logger.info({ safeTxHash, nonce }); + + try { + await safeAPIKit.proposeTransaction({ + safeAddress: wallet.address, + safeTxHash, + safeTransactionData: safeTransaction.data as any, + senderAddress: toChecksumAddress(await signer.getAddress()), + senderSignature: senderSignature.data, + }); + + logger.info(`Safe TX Proposed: ${safeTxHash}`); + return safeTxHash; + } catch (error) { + logger.error(error); + } +} + +async function confirmTransaction(wallet: WalletDocument, safeTxHash: string) { + const { ethAdapter } = getProvider(wallet.chainId); + const safe = await Safe.create({ + ethAdapter, + safeAddress: wallet.address, + contractNetworks, + }); + const signature = await safe.signTransactionHash(safeTxHash); + return await confirm(wallet, safeTxHash, signature.data); +} + +async function confirm(wallet: WalletDocument, safeTxHash: string, signatureData: string) { + const safeSDK = getSafeSDK(wallet.chainId); + return await safeSDK.confirmTransaction(safeTxHash, signatureData); +} + +async function executeTransaction(wallet: WalletDocument, safeTxHash: string) { + const { ethAdapter } = getProvider(wallet.chainId); + const safeService = getSafeSDK(wallet.chainId); + const safeSdk = await Safe.create({ + ethAdapter, + safeAddress: wallet.address, + contractNetworks, + }); + const safeTransaction = await safeService.getTransaction(safeTxHash); + const executeTxResponse = await safeSdk.executeTransaction(safeTransaction as any); + const receipt = await executeTxResponse.transactionResponse?.wait(); + const tx = await Transaction.findOne({ safeTxHash }); + + await TransactionService.executeCallback(tx, receipt as any); + + return receipt; +} + +async function getLastPendingTransactions(wallet: WalletDocument) { + const safeService = getSafeSDK(wallet.chainId); + const { results }: any = await safeService.getPendingTransactions(wallet.address); + + return results as unknown as SafeMultisigTransactionResponse[]; +} + +async function getTransaction(wallet: WalletDocument, safeTxHash: string): Promise { + const safeSDK = getSafeSDK(wallet.chainId); + return (await safeSDK.getTransaction(safeTxHash)) as unknown as SafeMultisigTransactionResponse; +} + +export default { + reset, + findById, + createSwapOwnerTransaction, + proposeTransaction, + confirmTransaction, + confirm, + getLastPendingTransactions, + getOwners, + create, + createJob, + findOneByAddress, + findOne, + getTransaction, + executeTransaction, + findOneByPool, +}; diff --git a/apps/api/src/app/services/THXService.ts b/apps/api/src/app/services/THXService.ts new file mode 100644 index 000000000..cdc75e67b --- /dev/null +++ b/apps/api/src/app/services/THXService.ts @@ -0,0 +1,37 @@ +import { THXAPIClient } from '@thxnetwork/sdk/clients'; +import { THX_CLIENT_ID, THX_CLIENT_SECRET } from '../config/secrets'; +import { Identity } from '../models'; +import AccountProxy from '../proxies/AccountProxy'; + +class THXService { + thx!: THXAPIClient; + + constructor() { + if (THX_CLIENT_ID && THX_CLIENT_SECRET) { + this.thx = new THXAPIClient({ + clientId: THX_CLIENT_ID, + clientSecret: THX_CLIENT_SECRET, + }); + } + } + + async connect(account: TAccount) { + if (!this.thx) return; + + if (!account.identity) { + account.identity = await this.thx.identity.create(); + await AccountProxy.update(account.sub, { identity: account.identity }); + } + + await Identity.updateOne({ uuid: account.identity }, { sub: account.sub }); + } + + async createEvent(account: TAccount, event: string) { + if (!this.thx || !account.identity) return; + await this.thx.events.create({ identity: account.identity, event }); + } +} + +const THXServiceInstance = new THXService(); + +export default THXServiceInstance; diff --git a/apps/api/src/app/services/TransactionService.ts b/apps/api/src/app/services/TransactionService.ts new file mode 100644 index 000000000..88c6c7d27 --- /dev/null +++ b/apps/api/src/app/services/TransactionService.ts @@ -0,0 +1,365 @@ +import { getProvider } from '@thxnetwork/api/util/network'; +import { ChainId, TransactionState, TransactionType } from '@thxnetwork/common/enums'; +import { MINIMUM_GAS_LIMIT, RELAYER_SPEED } from '@thxnetwork/api/config/secrets'; +import { paginatedResults } from '@thxnetwork/api/util/pagination'; +import { toChecksumAddress } from 'web3-utils'; +import { poll } from '@thxnetwork/api/util/polling'; +import { deployCallback as erc20DeployCallback } from './ERC20Service'; +import { RelayerTransactionPayload } from '@openzeppelin/defender-relay-client'; +import { Contract } from 'web3-eth-contract'; +import { Transaction, TransactionDocument, WalletDocument } from '@thxnetwork/api/models'; +import { TransactionReceipt } from 'web3-core'; +import ERC721Service from './ERC721Service'; +import ERC1155Service from './ERC1155Service'; +import SafeService from './SafeService'; + +function getById(id: string) { + return Transaction.findById(id); +} + +async function sendValue(to: string, value: string, chainId: ChainId) { + const { web3, defaultAccount } = getProvider(chainId); + const from = defaultAccount; + const gas = '21000'; + + let tx = await Transaction.create({ + state: TransactionState.Queued, + chainId, + from, + to, + gas, + }); + + const receipt = await web3.eth.sendTransaction({ + from, + to, + value, + gas, + }); + + if (receipt.transactionHash) { + tx.transactionHash = receipt.transactionHash; + tx.state = TransactionState.Mined; + tx = await tx.save(); + } + + return { tx, receipt }; +} + +async function send(to: string, fn: any, chainId: ChainId) { + const { web3, defaultAccount } = getProvider(chainId); + const from = defaultAccount; + const data = fn.encodeABI(); + const estimate = await fn.estimateGas({ from }); + const gas = estimate < MINIMUM_GAS_LIMIT ? MINIMUM_GAS_LIMIT : estimate; + + return web3.eth.sendTransaction({ + from, + to, + data, + gas, + }); +} + +/** + * Creates a transaction in the db and either executes or schedules a web3 transaction. + * + * When the chain has a relayer configured the transaction is scheduled through it instead of directly executed. + * + * By setting the forceSync bool to true you can force the call to behave synchronously. It will poll for the transaction to be executed and only return after the transaction and its callback are executed. + * + * @param to Recipient + * @param fn Web3 contract method + * @param chainId Chainid to execute on + * @param forceSync Boolean to force synchronous execution, this waits for the transaction to be processed before returning. + * @param callback Callback configuration. + * @returns The transaction ID. This can be stored so the status of the transaction can be queried. + */ +async function sendAsync( + to: string | null, + fn: any, + chainId: ChainId, + forceSync = true, + callback?: TTransactionCallback, +) { + const { web3, relayer, defaultAccount } = getProvider(chainId); + const data = fn.encodeABI(); + + const estimate = await fn.estimateGas({ from: defaultAccount }); + const gas = estimate < MINIMUM_GAS_LIMIT ? MINIMUM_GAS_LIMIT : estimate; + + const tx = await Transaction.create({ + type: relayer && !forceSync ? TransactionType.Relayed : TransactionType.Default, + state: TransactionState.Queued, + from: defaultAccount, + to, + chainId, + callback, + }); + if (relayer) { + const args: RelayerTransactionPayload = { + data, + speed: RELAYER_SPEED, + gasLimit: gas, + }; + if (to) args.to = to; + + const defenderTx = await relayer.sendTransaction(args); + + Object.assign(tx, { + transactionId: defenderTx.transactionId, + transactionHash: defenderTx.hash, + state: TransactionState.Sent, + }); + + await tx.save(); + + if (forceSync) { + await poll( + async () => { + const transaction = await getById(tx._id); + return queryTransactionStatusReceipt(transaction); + }, + (state: TransactionState) => state === TransactionState.Sent, + 500, + ); + } + } else { + const receipt = await web3.eth.sendTransaction({ + from: defaultAccount, + to, + data, + gas: gas + 100000, + }); + + await transactionMined(tx, receipt); + } + + // We return the id because the transaction might be out of date and the transaction is not used by callers anyway. + return String(tx._id); +} + +async function execSafeAsync(wallet: WalletDocument, tx: TransactionDocument) { + const { relayer } = getProvider(wallet.chainId); + const safeTransaction = await SafeService.getTransaction(wallet, tx.safeTxHash); + + // If there is no relayer for the network the safe executes immediately + if (!relayer) { + const receipt = await SafeService.executeTransaction(wallet, tx.safeTxHash); + await transactionMined(tx, receipt as any); + return; + } + + // If there is a relayer the transaction is sent to Defender and the job + // processor polls for the receipt and invokes callback + const defenderTx = await relayer.sendTransaction({ + to: safeTransaction.to, + data: safeTransaction.data, + gasLimit: safeTransaction.safeTxGas || '196000', + speed: RELAYER_SPEED, + }); + + await tx.updateOne({ + transactionId: defenderTx.transactionId, + transactionHash: defenderTx.hash, + state: TransactionState.Sent, + }); +} + +async function proposeSafeAsync( + wallet: WalletDocument, + to: string | null, + data: string, + callback?: TTransactionCallback, +) { + const { relayer, defaultAccount } = getProvider(wallet.chainId); + const safeTxHash = await SafeService.proposeTransaction(wallet, { + to, + data, + value: '0', + }); + + await SafeService.confirmTransaction(wallet, safeTxHash); + + return await Transaction.create({ + type: relayer ? TransactionType.Relayed : TransactionType.Default, + state: TransactionState.Confirmed, + safeTxHash, + chainId: wallet.chainId, + walletId: String(wallet._id), + from: defaultAccount, + to, + callback, + }); +} + +async function sendSafeAsync(wallet: WalletDocument, to: string | null, fn: any, callback?: TTransactionCallback) { + const data = fn.encodeABI(); + return proposeSafeAsync(wallet, to, data, callback); +} + +async function deploy(abi: any, bytecode: any, arg: any[], chainId: ChainId) { + const { web3, defaultAccount } = getProvider(chainId); + const contract = new web3.eth.Contract(abi) as unknown as Contract; + const gas = await contract + .deploy({ + data: bytecode, + arguments: arg, + }) + .estimateGas(); + const data = contract + .deploy({ + data: bytecode, + arguments: arg, + }) + .encodeABI(); + + const tx = await Transaction.create({ + type: TransactionType.Default, + state: TransactionState.Queued, + from: defaultAccount, + chainId, + gas, + }); + + const receipt = await web3.eth.sendTransaction({ + from: defaultAccount, + data, + gas, + }); + + if (receipt.transactionHash) { + await tx.updateOne({ + to: receipt.to, + transactionHash: receipt.transactionHash, + state: TransactionState.Mined, + }); + } + + contract.options.address = receipt.contractAddress; + + return contract; +} + +async function transactionMined(tx: TransactionDocument, receipt: TransactionReceipt) { + Object.assign(tx, { + transactionHash: receipt.transactionHash, + state: TransactionState.Failed, + }); + + if (receipt.to) { + Object.assign(tx, { to: toChecksumAddress(receipt.to) }); + } + + if (tx.callback) { + try { + await executeCallback(tx, receipt); + tx.state = TransactionState.Mined; + } catch (e) { + tx.failReason = e.message; + } + } + + await tx.save(); +} + +async function executeCallback(tx: TransactionDocument, receipt: TransactionReceipt) { + if (!tx || !tx.callback) return; + switch (tx.callback.type) { + case 'Erc20DeployCallback': + await erc20DeployCallback(tx.callback.args, receipt); + break; + case 'Erc721DeployCallback': + await ERC721Service.deployCallback(tx.callback.args, receipt); + break; + case 'ERC1155DeployCallback': + await ERC1155Service.deployCallback(tx.callback.args, receipt); + break; + case 'erc721TokenMintCallback': + await ERC721Service.mintCallback(tx.callback.args, receipt); + break; + case 'erc1155TokenMintCallback': + await ERC1155Service.mintCallback(tx.callback.args, receipt); + break; + case 'erc721nTransferFromCallback': + await ERC721Service.transferFromCallback(tx.callback.args, receipt); + break; + case 'erc1155TransferFromCallback': + await ERC1155Service.transferFromCallback(tx.callback.args, receipt); + break; + } +} + +async function queryTransactionStatusDefender(tx: TransactionDocument) { + if ([TransactionState.Mined, TransactionState.Failed].includes(tx.state)) { + return tx; + } + const { web3, relayer } = getProvider(tx.chainId); + + const defenderTx = await relayer.query(tx.transactionId); + + // Hash has been updated + if (tx.transactionHash != defenderTx.hash) { + tx.transactionHash = defenderTx.hash; + await tx.save(); + } + + if (['mined', 'confirmed'].includes(defenderTx.status)) { + const receipt = await web3.eth.getTransactionReceipt(tx.transactionHash); + await transactionMined(tx, receipt); + } else if (defenderTx.status === 'failed') { + tx.state = TransactionState.Failed; + await tx.save(); + } + + return tx.state; +} + +async function queryTransactionStatusReceipt(tx: TransactionDocument) { + if ([TransactionState.Mined, TransactionState.Failed].includes(tx.state)) { + return tx; + } + const { web3 } = getProvider(tx.chainId); + + const receipt = await web3.eth.getTransactionReceipt(tx.transactionHash); + + if (receipt) { + // Wait 500 ms for transactions to be propagated to all nodes. + // Since we use multiple RPCs it happens we already have the receipt but the other RPC + // doesn't have the block available yet. + await new Promise((done) => setTimeout(done, 500)); + + await transactionMined(tx, receipt); + } + + return tx.state; +} + +async function findByQuery(poolAddress: string, page = 1, limit = 10, startDate?: Date, endDate?: Date) { + const query: Record = { to: poolAddress }; + + if (startDate || endDate) query.createdAt = {}; + if (startDate) { + query.createdAt['$gte'] = startDate; + } + if (endDate) { + query.createdAt['$lt'] = endDate; + } + + return paginatedResults(Transaction, page, limit, query); +} + +export default { + getById, + send, + sendAsync, + deploy, + sendValue, + findByQuery, + sendSafeAsync, + execSafeAsync, + queryTransactionStatusDefender, + queryTransactionStatusReceipt, + executeCallback, + proposeSafeAsync, +}; diff --git a/apps/api/src/app/services/TwitterCacheService.ts b/apps/api/src/app/services/TwitterCacheService.ts new file mode 100644 index 000000000..cdd79d4ee --- /dev/null +++ b/apps/api/src/app/services/TwitterCacheService.ts @@ -0,0 +1,264 @@ +import { AccessTokenKind, JobType, OAuthRequiredScopes, OAuthTwitterScope } from '@thxnetwork/common/enums'; +import { agenda } from '../util/agenda'; +import { Job, QuestSocial, TwitterLike, TwitterQueryDocument, TwitterRepost, TwitterUser } from '../models'; +import { AxiosResponse } from 'axios'; +import { logger } from '../util/logger'; +import AccountProxy from '../proxies/AccountProxy'; +import TwitterDataProxy from '../proxies/TwitterDataProxy'; +import { TwitterPost } from '../models/TwitterPost'; + +function findUserById(users: { id: string }[], userId: string) { + return users.find((user: { id: string }) => user.id === userId); +} + +export default class TwitterCacheService { + static savePosts(posts: TTwitterPostWithUserAndMedia[] = [], query?: TwitterQueryDocument) { + return Promise.all( + posts.map(async (post) => { + await this.savePost(post, post.media, query); + await this.saveUser(post.user); + }), + ); + } + + static savePost(post: TTwitterPostResponse, media: TTwitterMediaResponse[] = [], query?: TwitterQueryDocument) { + return TwitterPost.findOneAndUpdate( + { + postId: post.id, + queryId: query && query.id, + }, + { + postId: post.id, + queryId: query && query.id, + userId: post.author_id, + text: post.text, + media: media.map((m: TTwitterMediaResponse) => ({ + url: m.url, + type: m.type, + previewImageUrl: m.preview_image_url, + width: m.width, + height: m.height, + })), + }, + { upsert: true, new: true }, + ); + } + + static saveUser(user: TTwitterUserResponse) { + if (!user) return; + return TwitterUser.findOneAndUpdate( + { userId: user.id }, + { + userId: user.id, + profileImgUrl: user.profile_image_url, + name: user.name, + username: user.username, + publicMetrics: { + followersCount: user.public_metrics.followers_count, + followingCount: user.public_metrics.following_count, + tweetCount: user.public_metrics.tweet_count, + listedCount: user.public_metrics.listed_count, + likeCount: user.public_metrics.like_count, + }, + }, + { upsert: true, new: true }, + ); + } + + static async updatePostCache( + account: TAccount, + quest: TQuestSocial, + token: TToken, + params: TTwitterRequestParams = { max_results: 100 }, + ) { + try { + logger.info(`[${quest.poolId}][${account.sub}] X Quest ${quest._id} Post verification calls X API.`); + const data = await TwitterDataProxy.request(token, { + url: `/tweets/search/recent`, + method: 'GET', + params, + }); + logger.info(`Fetched ${data.meta.result_count} reposts from X.`); + + // If no results return early + if (!data.meta.result_count) return; + } catch (res) { + await this.handleRateLimitError(res, account, quest, params, JobType.UpdateTwitterRepostCache); + } + } + + static async updateRepostCache( + account: TAccount, + quest: TQuestSocial, + token: TToken, + params: TTwitterRequestParams = { max_results: 100 }, + ) { + const postId = quest.content; + try { + logger.info(`[${quest.poolId}][${account.sub}] X Quest ${quest._id} Repost verification calls X API.`); + const data = await TwitterDataProxy.request(token, { + url: `/tweets/${postId}/retweeted_by`, + method: 'GET', + params, + }); + logger.info(`Fetched ${data.meta.result_count} reposts from X.`); + + // If no results return early + if (!data.meta.result_count) return; + + // If not then we upsert all TwitterReposts into the database + const operations = data.data.map((user: { id: string }) => ({ + updateOne: { + filter: { userId: user.id, postId }, + update: { userId: user.id, postId }, + upsert: true, + }, + })); + await TwitterRepost.bulkWrite(operations); + + // If the user has reposted the post, we return early + if (findUserById(data.data, token.userId)) return; + + // If there is a next_token, we store the next_token in case we get rate limited + // and continue on the next page + if (data.meta.next_token) { + // Start with caching the next 100 results + await this.updateRepostCache(account, quest, token, { + ...params, + pagination_token: data.meta.next_token, + }); + } + } catch (res) { + await this.handleRateLimitError(res, account, quest, params, JobType.UpdateTwitterRepostCache); + } + } + + static async updateLikeCache( + account: TAccount, + quest: TQuestSocial, + token: TToken, + params: TTwitterRequestParams = { max_results: 100 }, + ) { + const postId = quest.content; + + try { + logger.info(`[${quest.poolId}][${account.sub}] X Quest ${quest._id} Like verification calls X API.`); + const data = await TwitterDataProxy.request(token, { + url: `/tweets/${postId}/liking_users`, + method: 'GET', + params, + }); + logger.info(`Fetched ${data.meta.result_count} likes from X.`); + + // If no results return early + if (!data.meta.result_count) return; + + // If not then we upsert all TwitterLikes into the database + const operations = data.data.map((user: { id: string }) => ({ + updateOne: { + filter: { userId: user.id, postId }, + update: { userId: user.id, postId }, + upsert: true, + }, + })); + await TwitterLike.bulkWrite(operations); + + // If the user has liked the post, we return early + if (findUserById(data.data, token.userId)) return; + + // If there is a next_token, we store the next_token in case we get rate limited + // and continue on the next page + if (data.meta.next_token) { + // Start with caching the next 100 results + await this.updateLikeCache(account, quest, token, { + ...params, + pagination_token: data.meta.next_token, + }); + } + } catch (res) { + await this.handleRateLimitError(res, account, quest, params, JobType.UpdateTwitterLikeCache); + } + } + + static async handleRateLimitError( + res: AxiosResponse, + account: TAccount, + quest: TQuestSocial, + params: TTwitterRequestParams, + jobType: JobType, + ) { + // Retrow the error if it's not a rate limit error + if (res.status === 429) { + const sub = account.sub; + const questId = String(quest._id); + const job = await Job.findOne({ + 'name': jobType, + 'data.sub': sub, + 'data.questId': questId, + }); + + if (!job) { + const resetTime = Number(res.headers['x-rate-limit-reset']); + const seconds = resetTime - Math.ceil(Date.now() / 1000); + const minutes = Math.ceil(seconds / 60); + + // Resume caching when rate limit is reset + await agenda.schedule(`in ${minutes} minutes`, jobType, { + sub, + questId, + params, + }); + + logger.info('Scheduled updateLikeCacheJob', { questId, sub, params }); + } + } + + throw res; + } + + static async updateRepostCacheJob(job: TJob) { + await this.updateCacheJob(job, OAuthRequiredScopes.TwitterValidateRepost, this.updateRepostCache.bind(this)); + } + + static async updateLikeCacheJob(job: TJob) { + await this.updateCacheJob(job, OAuthRequiredScopes.TwitterValidateLike, this.updateLikeCache.bind(this)); + } + + static async updateCacheJob( + job: TJob, + scopes: OAuthTwitterScope[], + updateCacheCallback: ( + account: TAccount, + quest: TQuestSocial, + token: TToken, + params: TTwitterRequestParams, + ) => Promise, + ) { + const { questId, sub, params } = job.attrs.data as { + sub: string; + questId: string; + params: TTwitterRequestParams; + }; + logger.info(`Starting ${job.attrs.name}`, params); + + try { + const quest = await QuestSocial.findById(questId); + if (!quest) throw new Error(`No token found for questId ${questId}.`); + + const account = await AccountProxy.findById(sub); + if (!account) throw new Error(`No account found for sub ${sub}.`); + + const token = await AccountProxy.getToken(account, AccessTokenKind.Twitter, scopes); + if (!token) throw new Error(`No token found for sub ${sub}.`); + + // Remove this job so it can be recreated if another rate limit is hit + await job.remove(); + + // Continue cache update for likes or reposts until the last page is reached + // or the next rate limit is hit + await updateCacheCallback(account, quest, token, params); + } catch (error) { + logger.error(error.response ? error.response : error); + } + } +} diff --git a/apps/api/src/app/services/TwitterQueryService.ts b/apps/api/src/app/services/TwitterQueryService.ts new file mode 100644 index 000000000..c88aae3c7 --- /dev/null +++ b/apps/api/src/app/services/TwitterQueryService.ts @@ -0,0 +1,131 @@ +import QuestService from './QuestService'; +import TwitterDataProxy from '../proxies/TwitterDataProxy'; +import TwitterCacheService from './TwitterCacheService'; +import MailService from './MailService'; +import AccountProxy from '../proxies/AccountProxy'; +import { Pool, PoolDocument, QuestSocial, TwitterQuery, TwitterQueryDocument } from '../models'; +import { DASHBOARD_URL } from '../config/secrets'; +import { QuestSocialRequirement, QuestVariant } from '@thxnetwork/common/enums'; +import { logger } from '../util/logger'; +import { TwitterPost } from '../models/TwitterPost'; + +const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; + +export default class TwitterQueryService { + static async searchJob() { + const queries = await TwitterQuery.find(); + await this.run(queries); + } + + static async list(query: { poolId: string }): Promise { + const queries = await TwitterQuery.find(query); + return await Promise.all( + queries.map(async (query) => { + const posts = await TwitterPost.find({ queryId: query.id }); + return { ...query.toJSON(), posts }; + }), + ); + } + + static async run(queries: TwitterQueryDocument[]) { + const poolIds = queries.map((query) => query.poolId); + const pools = await Pool.find({ _id: { $in: poolIds } }); + const subs = pools.map((pool) => pool.sub); + const accounts = await AccountProxy.find({ subs }); + + for (const query of queries) { + try { + const pool = pools.find((pool) => pool.id === query.poolId); + if (!pool) continue; + + const account = accounts.find((account) => account.sub === pool.sub); + if (!account) continue; + + const posts = await this.search(account, query); + if (!posts.length) continue; + + // Send notification to campaign owner if new quests are created + await this.sendMail(account, pool, posts); + } catch (error) { + logger.error(error); + } + } + } + + static async search(account: TAccount, query: TwitterQueryDocument) { + const posts = await TwitterDataProxy.search(account, query.query); + + // Filter out the posts that already have a quest + const postIds = posts.map((post) => post.id); + const quests = await QuestSocial.find({ poolId: query.poolId, content: { $in: postIds } }); + const postsWithoutQuest = posts.filter((post) => { + return !quests.some((quest) => quest.content === post.id); + }); + + // Iterate over posts and create quests + for (const post of postsWithoutQuest) { + await this.createQuest( + query, + await TwitterCacheService.savePost(post, post.media, query), + await TwitterCacheService.saveUser(post.user), + ); + } + + return postsWithoutQuest; + } + + static async sendMail(account: TAccount, pool: PoolDocument, posts: TTwitterPostWithUserAndMedia[]) { + const src = new URL(DASHBOARD_URL); + src.pathname = `/pool/${pool.id}/quests`; + src.searchParams.append('isPublished', 'false'); + + await MailService.send( + account.email, + '👀 New matches for your query!', + `

Hi!

+

We found matches for your X query!

+ ${posts + .map( + (post) => + `
${post.user.username} (${ + post.public_metrics.impression_count + } views)
+

${post.text.substring(0, 100)}... + View Post +

`, + ) + .join('
')} + `, + { src: src.toString(), text: 'Publish Quests' }, + ); + } + + static async createQuest(query: TTwitterQuery, post: TTwitterPost, user: TTwitterUser) { + const file = null; // TODO Download buffer for the media first URL and upload with quest + const quest = { + kind: 'twitter', + interaction: QuestSocialRequirement.TwitterLikeRetweet, + title: 'Repost & Like', + description: query.defaults.description, + amount: query.defaults.amount, + locks: query.defaults.locks, + isPublished: query.defaults.isPublished, + content: post.postId, + contentMetadata: JSON.stringify({ + url: `https://twitter.com/${user.username.toLowerCase()}/status/${post.postId}`, + username: user.username, + name: user.name, + text: post.text, + minFollowersCount: query.defaults.minFollowersCount, + }), + }; + + if (query.defaults.expiryInDays > 0) { + quest['expiryDate'] = new Date(Date.now() + query.defaults.expiryInDays * ONE_DAY_IN_MS); + } + + await QuestService.create(QuestVariant.Twitter, query.poolId, quest, file); + } +} diff --git a/apps/api/src/app/services/VoteEscrowService.ts b/apps/api/src/app/services/VoteEscrowService.ts new file mode 100644 index 000000000..7e8274314 --- /dev/null +++ b/apps/api/src/app/services/VoteEscrowService.ts @@ -0,0 +1,200 @@ +import { getProvider } from '@thxnetwork/api/util/network'; +import { contractArtifacts, contractNetworks } from '@thxnetwork/api/contracts'; +import { ChainId } from '@thxnetwork/common/enums'; +import { WalletDocument } from '@thxnetwork/api/models'; +import { toChecksumAddress } from 'web3-utils'; +import TransactionService from '@thxnetwork/api/services/TransactionService'; +import { logger } from '../util/logger'; +import { NODE_ENV } from '../config/secrets'; + +async function isApprovedAddress(address: string, chainId: ChainId) { + const { web3 } = getProvider(chainId); + const whitelist = new web3.eth.Contract( + contractArtifacts['SmartWalletWhitelist'].abi, + contractNetworks[chainId].SmartWalletWhitelist, + ); + return await whitelist.methods.check(address).call(); +} + +async function list(wallet: WalletDocument) { + const { web3 } = getProvider(wallet.chainId); + const ve = new web3.eth.Contract( + contractArtifacts['VotingEscrow'].abi, + contractNetworks[wallet.chainId].VotingEscrow, + ); + return await ve.methods.locked(wallet.address).call(); +} + +async function getAllowance(wallet: WalletDocument, tokenAddress: string, spender: string) { + const { web3 } = getProvider(wallet.chainId); + const bpt = new web3.eth.Contract(contractArtifacts['BPT'].abi, tokenAddress); + return await bpt.methods.allowance(wallet.address, spender).call(); +} + +async function approve(wallet: WalletDocument, tokenAddress: string, spender: string, amount: string) { + const { web3 } = getProvider(wallet.chainId); + const bpt = new web3.eth.Contract(contractArtifacts['BPT'].abi, tokenAddress); + const fn = bpt.methods.approve(spender, amount); + + // Propose tx data to relayer and return safeTxHash to client to sign + return await TransactionService.sendSafeAsync(wallet, bpt.options.address, fn); +} + +async function increaseAmount(wallet: WalletDocument, amountInWei: string) { + const { web3 } = getProvider(wallet.chainId); + const ve = new web3.eth.Contract( + contractArtifacts['VotingEscrow'].abi, + contractNetworks[wallet.chainId].VotingEscrow, + ); + const fn = ve.methods.increase_amount(amountInWei); + return TransactionService.sendSafeAsync(wallet, contractNetworks[wallet.chainId].VotingEscrow, fn); +} + +async function increaseUnlockTime(wallet: WalletDocument, endTimestamp: number) { + const { web3 } = getProvider(wallet.chainId); + const ve = new web3.eth.Contract( + contractArtifacts['VotingEscrow'].abi, + contractNetworks[wallet.chainId].VotingEscrow, + ); + const fn = ve.methods.increase_unlock_time(endTimestamp); + return TransactionService.sendSafeAsync(wallet, contractNetworks[wallet.chainId].VotingEscrow, fn); +} + +async function deposit(wallet: WalletDocument, amountInWei: string, endTimestamp: number) { + const { web3 } = getProvider(wallet.chainId); + const ve = new web3.eth.Contract( + contractArtifacts['VotingEscrow'].abi, + contractNetworks[wallet.chainId].VotingEscrow, + ); + const fn = ve.methods.create_lock(amountInWei, endTimestamp); + return TransactionService.sendSafeAsync(wallet, contractNetworks[wallet.chainId].VotingEscrow, fn); +} + +async function withdraw(wallet: WalletDocument, isEarlyWithdraw: boolean) { + const { web3 } = getProvider(wallet.chainId); + const ve = new web3.eth.Contract( + contractArtifacts['VotingEscrow'].abi, + contractNetworks[wallet.chainId].VotingEscrow, + ); + + // Check for lock and determine ve function to call + const fn = isEarlyWithdraw ? ve.methods.withdraw_early() : ve.methods.withdraw(); + + // Propose tx data to relayer and return safeTxHash to client to sign + const tx = await TransactionService.sendSafeAsync(wallet, ve.options.address, fn); + + return [tx]; +} + +async function listRewards(wallet: WalletDocument) { + const { web3 } = getProvider(wallet.chainId); + + // Get reward tokens + const lr = new web3.eth.Contract(contractArtifacts['LensReward'].abi, contractNetworks[wallet.chainId].LensReward); + + // Call static + const rewardTokens = [contractNetworks[wallet.chainId].BAL, contractNetworks[wallet.chainId].BPT]; + const callStatic = async (fn) => { + const result = await web3.eth.call({ + to: contractNetworks[wallet.chainId].LensReward, + data: fn.encodeABI(), + from: toChecksumAddress(wallet.address), + }); + return web3.eth.abi.decodeParameters( + [ + { + type: 'tuple[]', + components: [ + { type: 'address', name: 'tokenAddress' }, + { type: 'uint256', name: 'amount' }, + ], + }, + ], + result, + ); + }; + + // Call static on rewards + const rewards = await callStatic( + lr.methods.getUserClaimableRewardsAll( + contractNetworks[wallet.chainId].RewardDistributor, + toChecksumAddress(wallet.address), + rewardTokens, + ), + ); + + // Util functions to get amount for tokenAddress from call + const getAmount = (tokenAddress: string) => { + return rewards['0'].find((r) => r.tokenAddress === tokenAddress)?.amount; + }; + const { BAL, BPT } = contractNetworks[wallet.chainId]; + + return [ + { + tokenAddress: BAL, + amount: getAmount(BAL), + symbol: 'BAL', + }, + { + tokenAddress: BPT, + amount: getAmount(BPT), + symbol: '20USDC-80THX', + }, + ]; +} + +async function claimTokens(wallet: WalletDocument) { + const { web3 } = getProvider(wallet.chainId); + const rewardDistributor = new web3.eth.Contract( + contractArtifacts['RewardDistributor'].abi, + contractNetworks[wallet.chainId].RewardDistributor, + ); + + // List reward tokens and build function call + const rewardTokens = await rewardDistributor.methods.getAllowedRewardTokens().call(); + const fn = rewardDistributor.methods.claimTokens(wallet.address, rewardTokens); + + // Propose tx data to relayer and return safeTxHash to client to sign + const tx = await TransactionService.sendSafeAsync(wallet, rewardDistributor.options.address, fn); + + return [tx]; +} + +async function claimExternalRewardsJob() { + for (const chainId of [ChainId.Hardhat, ChainId.Polygon]) { + try { + if (NODE_ENV === 'production' && chainId === ChainId.Hardhat) continue; + const { web3 } = getProvider(chainId); + const ve = new web3.eth.Contract( + contractArtifacts['VotingEscrow'].abi, + contractNetworks[chainId].VotingEscrow, + ); + + // Execute directly using the relayer + await ve.methods.claimExternalRewards().send(); + + const receipt = await TransactionService.send( + ve.options.address, + ve.methods.claimExternalRewards(), + chainId, + ); + logger.info(`ClaimExternalRewards: ${receipt.transactionHash}`); + } catch (error) { + logger.error(`ClaimExternalRewards: ${error && error.message}`); + } + } +} + +export default { + claimExternalRewardsJob, + list, + isApprovedAddress, + approve, + getAllowance, + deposit, + withdraw, + listRewards, + claimTokens, + increaseAmount, + increaseUnlockTime, +}; diff --git a/apps/api/src/app/services/WalletService.ts b/apps/api/src/app/services/WalletService.ts new file mode 100644 index 000000000..ba48e38a4 --- /dev/null +++ b/apps/api/src/app/services/WalletService.ts @@ -0,0 +1,66 @@ +import { Wallet } from '@thxnetwork/api/models/Wallet'; +import { TransactionState, WalletVariant } from '@thxnetwork/common/enums'; +import { Transaction } from '@thxnetwork/api/models/Transaction'; +import { getChainId, safeVersion } from './ContractService'; +import SafeService from './SafeService'; + +export default class WalletService { + static findById(id: string) { + if (!id) return; + return Wallet.findById(id); + } + + static async list(account: TAccount): Promise { + // List all wallets owned by the account but filter out wallets used for the campaign + const wallets = await Wallet.find({ + sub: account.sub, + variant: { $in: [WalletVariant.Safe, WalletVariant.WalletConnect] }, + address: { $exists: true, $ne: null }, + poolId: { $exists: false }, + }); + + return await Promise.all( + wallets.map(async (wallet) => { + const pendingTransactions = await Transaction.find({ + walletId: String(wallet._id), + state: TransactionState.Confirmed, + }); + const short = wallet.address && WalletService.formatAddress(wallet.address); + + return { ...wallet.toJSON(), short, pendingTransactions }; + }), + ); + } + + static findOne(query: Partial) { + return Wallet.findOne(query); + } + + static formatAddress(address: string) { + return `${address.slice(0, 5)}...${address.slice(-3)}`; + } + + static create(variant: WalletVariant, data: Partial) { + const chainId = getChainId(); + const map = { + [WalletVariant.Safe]: WalletService.createSafe, + [WalletVariant.WalletConnect]: WalletService.createWalletConnect, + }; + return map[variant]({ ...(data as TWallet), chainId }); + } + + static async createSafe({ sub, address, chainId }) { + const safeWallet = await SafeService.findOne({ sub }); + // An account can have max 1 Safe + if (safeWallet) throw new Error('Already has a Safe.'); + + // Deploy a Safe with Web3Auth address and relayer as signers + await SafeService.create({ sub, chainId, safeVersion }, address); + } + + static async createWalletConnect({ sub, address, chainId }) { + const data: Partial = { variant: WalletVariant.WalletConnect, sub, address, chainId }; + + await Wallet.findOneAndUpdate({ sub, address, chainId }, data, { upsert: true }); + } +} diff --git a/apps/api/src/app/services/WebhookService.ts b/apps/api/src/app/services/WebhookService.ts new file mode 100644 index 000000000..48cb7b940 --- /dev/null +++ b/apps/api/src/app/services/WebhookService.ts @@ -0,0 +1,94 @@ +import axios from 'axios'; +import { Pool } from '@thxnetwork/api/models'; +import { Webhook, WebhookDocument } from '@thxnetwork/api/models/Webhook'; +import { Identity } from '@thxnetwork/api/models/Identity'; +import { WebhookRequest, WebhookRequestDocument } from '@thxnetwork/api/models/WebhookRequest'; +import { Job } from '@hokify/agenda'; +import { agenda } from '@thxnetwork/api/util/agenda'; +import { signPayload } from '@thxnetwork/api/util/signingsecret'; +import { JobType, Event, WebhookRequestState } from '@thxnetwork/common/enums'; + +export default class WebhookService { + static async request(webhook: WebhookDocument, account: TAccount, metadata?: string) { + const identities = (await Identity.find({ poolId: webhook.poolId, sub: account.sub })).map((i) => i.uuid); + const webhookRequest = await WebhookRequest.create({ + webhookId: webhook._id, + payload: JSON.stringify({ type: 'quest_entry.create', identities, metadata }), + state: WebhookRequestState.Pending, + }); + + return await this.executeRequest(webhook, webhookRequest); + } + + static async requestAsync( + webhook: WebhookDocument, + sub: string, + payload: { type: Event; data: any & { metadata: any } }, + ) { + const identities = (await Identity.find({ poolId: webhook.poolId, sub })).map((i) => i.uuid); + const webhookRequest = await WebhookRequest.create({ + webhookId: webhook._id, + payload: JSON.stringify({ ...payload, identities }), + state: WebhookRequestState.Pending, + }); + + await agenda.now(JobType.RequestAttemp, { + webhookRequestId: String(webhookRequest._id), + poolId: webhook.poolId, + }); + } + + static async requestAttemptJob(job: Job) { + const { webhookRequestId } = job.attrs.data as any; + + const webhookRequest = await WebhookRequest.findById(webhookRequestId); + if (!webhookRequest) throw new Error('No webhook request object found'); + + const webhook = await Webhook.findById(webhookRequest.webhookId); + if (!webhook) throw new Error('No webhook object found'); + + await this.executeRequest(webhook, webhookRequest); + } + + static async executeRequest(webhook: WebhookDocument, webhookRequest: WebhookRequestDocument) { + try { + const pool = await Pool.findById(webhook.poolId); + if (!pool.signingSecret) throw new Error('No signing secret found'); + + const signature = signPayload(webhookRequest.payload, pool.signingSecret); + webhookRequest.state = WebhookRequestState.Sent; + + const response = await axios({ + method: 'POST', + url: webhook.url, + data: { signature, payload: webhookRequest.payload }, + headers: { + 'Content-Type': 'application/json', + }, + }); + + webhookRequest.state = WebhookRequestState.Received; + webhookRequest.httpStatus = response.status; + + console.debug(`[${response.status}], ${JSON.stringify(response.data)}`); + + return response && response.data; + } catch (error) { + console.log(error); + + webhookRequest.state = WebhookRequestState.Failed; + webhookRequest.failReason = error && error.toString(); + + // If there is an HTTP response we store the HTTP error and status code + if (error && error.response) { + webhookRequest.httpStatus = error.response.status; + webhookRequest.failReason = JSON.stringify(error.response.data); + } + + console.error(error); + } finally { + webhookRequest.attempts = webhookRequest.attempts++; + await webhookRequest.save(); + } + } +} diff --git a/apps/api/src/app/services/index.ts b/apps/api/src/app/services/index.ts new file mode 100644 index 000000000..bdf4232e9 --- /dev/null +++ b/apps/api/src/app/services/index.ts @@ -0,0 +1,49 @@ +export * as AnalyticsService from './AnalyticsService'; +export * as BalancerService from './BalancerService'; +export * as BrandService from './BrandService'; +export * as CanvasService from './CanvasService'; +export * as ClaimService from './ClaimService'; +export * as ContractService from './ContractService'; +export * as DiscordService from './DiscordService'; +export * as ERC1155Service from './ERC1155Service'; +export * as ERC20Service from './ERC20Service'; +export * as ERC721Service from './ERC721Service'; +export * as GalachainService from './GalachainService'; +export * as GitcoinService from './GitcoinService'; +export * as IPFSService from './IPFSService'; +export * as IdentityService from './IdentityService'; +export * as ImageService from './ImageService'; +export * as InvoiceService from './InvoiceService'; +export * as LiquidityService from './LiquidityService'; +export * as LockService from './LockService'; +export * as MailService from './MailService'; +export * as NotificationService from './NotificationService'; +export * as ParticipantService from './ParticipantService'; +export * as PaymentService from './PaymentService'; +export * as PointBalanceService from './PointBalanceService'; +export * as PoolService from './PoolService'; +export * as QuestCustomService from './QuestCustomService'; +export * as QuestDailyService from './QuestDailyService'; +export * as QuestDiscordService from './QuestDiscordService'; +export * as QuestGitcoinService from './QuestGitcoinService'; +export * as QuestInviteService from './QuestInviteService'; +export * as QuestService from './QuestService'; +export * as QuestSocialService from './QuestSocialService'; +export * as QuestWeb3Service from './QuestWeb3Service'; +export * as QuestWebhookService from './QuestWebhookService'; +export * as ReCaptchaService from './ReCaptchaService'; +export * as RewardCoinService from './RewardCoinService'; +export * as RewardCouponService from './RewardCouponService'; +export * as RewardCustomService from './RewardCustomService'; +export * as RewardDiscordRoleService from './RewardDiscordRoleService'; +export * as RewardGalachainService from './RewardGalachainService'; +export * as RewardNFTService from './RewardNFTService'; +export * as RewardService from './RewardService'; +export * as SafeService from './SafeService'; +export * as THXService from './THXService'; +export * as TransactionService from './TransactionService'; +export * as TwitterCacheService from './TwitterCacheService'; +export * as TwitterQueryService from './TwitterQueryService'; +export * as VoteEscrowService from './VoteEscrowService'; +export * as WalletService from './WalletService'; +export * as WebhookService from './WebhookService'; diff --git a/apps/api/src/app/services/interfaces/IGalaService.ts b/apps/api/src/app/services/interfaces/IGalaService.ts new file mode 100644 index 000000000..67731f3cd --- /dev/null +++ b/apps/api/src/app/services/interfaces/IGalaService.ts @@ -0,0 +1,26 @@ +import { TokenInstanceKey, TokenClassKey, RegisterUserDto, UserProfile } from '@gala-chain/api'; + +interface CustomProfileAPI { + GetProfile(privateKey: string): Promise; + RegisterEthUser(publicKey: string): Promise; +} + +interface CustomTokenAPI { + CoinBalanceOf({ tokenInstance, owner }: { tokenInstance: TokenInstanceKey; owner: string }): Promise; + CoinCreate( + tokenInfo: { + image: string; + name: string; + description: string; + symbol: string; + decimals: number; + maxSupply: any; + }, + privateKey: string, + ): Promise; + CoinApprove(options: { spender: string; amount: number }, privateKey: string): Promise; + CoinMint(options: { to: string; amount: number }, privateKey: string): Promise; + CoinTransfer(options: { to: string; amount: number }, privateKey: string): Promise; +} + +export { CustomProfileAPI, CustomTokenAPI }; diff --git a/apps/api/src/app/services/interfaces/IQuestService.ts b/apps/api/src/app/services/interfaces/IQuestService.ts new file mode 100644 index 000000000..777650f9c --- /dev/null +++ b/apps/api/src/app/services/interfaces/IQuestService.ts @@ -0,0 +1,38 @@ +import { Model } from 'mongoose'; +import QuestInviteService from '../QuestInviteService'; +import QuestDiscordService from '../QuestDiscordService'; +import QuestTwitterService from '../QuestSocialService'; // Split +import QuestYouTubeService from '../QuestSocialService'; // Split +import QuestDailyService from '../QuestDailyService'; +import QuestCustomService from '../QuestCustomService'; +import QuestGitcoinService from '../QuestGitcoinService'; +import QuestWeb3Service from '../QuestWeb3Service'; +import QuestWebhookService from '../QuestWebhookService'; +import { QuestVariant } from '@thxnetwork/common/enums'; + +export interface IQuestService { + models: { quest: Model; entry: Model }; + decorate(options: { quest: TQuest; account?: TAccount; data: Partial }): Promise; + isAvailable(options: { quest: TQuest; account?: TAccount; data: Partial }): Promise; + getAmount(options: { quest: TQuest; account?: TAccount }): Promise; + getValidationResult(options: { + quest: TQuest; + account: TAccount; + data: Partial; + }): Promise; + findEntryMetadata(options: { quest: TQuest }); +} + +export const serviceMap: { + [variant: number]: IQuestService; +} = { + [QuestVariant.Daily]: new QuestDailyService(), + [QuestVariant.Invite]: new QuestInviteService(), + [QuestVariant.Discord]: new QuestDiscordService(), + [QuestVariant.Twitter]: new QuestTwitterService(), + [QuestVariant.YouTube]: new QuestYouTubeService(), + [QuestVariant.Custom]: new QuestCustomService(), + [QuestVariant.Web3]: new QuestWeb3Service(), + [QuestVariant.Gitcoin]: new QuestGitcoinService(), + [QuestVariant.Webhook]: new QuestWebhookService(), +}; diff --git a/apps/api/src/app/services/interfaces/IRewardService.ts b/apps/api/src/app/services/interfaces/IRewardService.ts new file mode 100644 index 000000000..0ffd58ac3 --- /dev/null +++ b/apps/api/src/app/services/interfaces/IRewardService.ts @@ -0,0 +1,32 @@ +import { Model } from 'mongoose'; +import { WalletDocument } from '@thxnetwork/api/models'; + +export interface IRewardService { + models: { + reward: Model; + payment: Model; + }; + decorate(data: { reward: TReward; account?: TAccount }): Promise; + decoratePayment(payment: TRewardPayment): Promise; + getValidationResult(data: { + reward: TReward; + wallet?: WalletDocument; + safe?: WalletDocument; + account?: TAccount; + }): Promise; + create(data: Partial): Promise; + update(reward: TReward, updates: Partial): Promise; + remove(reward: TReward): Promise; + findById(id: string): Promise; + createPayment({ + reward, + account, + safe, + wallet, + }: { + reward: TReward; + account: TAccount; + safe: WalletDocument; + wallet?: WalletDocument; + }): Promise; +} diff --git a/apps/api/src/app/services/maps/quests.ts b/apps/api/src/app/services/maps/quests.ts new file mode 100644 index 000000000..bd13ebc3d --- /dev/null +++ b/apps/api/src/app/services/maps/quests.ts @@ -0,0 +1,114 @@ +import { AccessTokenKind, QuestSocialRequirement, OAuthScope, OAuthRequiredScopes } from '@thxnetwork/common/enums'; +import { logger } from '@thxnetwork/api/util/logger'; +import DiscordDataProxy from '@thxnetwork/api/proxies/DiscordDataProxy'; +import TwitterDataProxy from '@thxnetwork/api/proxies/TwitterDataProxy'; +import YouTubeDataProxy from '@thxnetwork/api/proxies/YoutubeDataProxy'; + +export const requirementMap: { + [interaction: number]: (account: TAccount, quest: TQuestSocial) => Promise; +} = { + [QuestSocialRequirement.YouTubeLike]: async (account, quest) => { + return await YouTubeDataProxy.validateLike(account, quest.content); + }, + [QuestSocialRequirement.YouTubeSubscribe]: async (account, quest) => { + return await YouTubeDataProxy.validateSubscribe(account, quest.content); + }, + [QuestSocialRequirement.TwitterLike]: async (account, quest) => { + logger.info(`[${quest.poolId}][${account.sub}] X Quest ${quest._id} Like verification started`); + + const validationResultUser = await TwitterDataProxy.validateUser(account, quest); + if (!validationResultUser.result) return validationResultUser; + const validationResultLike = await TwitterDataProxy.validateLike(account, quest); + if (!validationResultLike.result) return validationResultLike; + }, + [QuestSocialRequirement.TwitterRetweet]: async (account, quest) => { + logger.info(`[${quest.poolId}][${account.sub}] X Quest ${quest._id} Repost verification started`); + + const validationResultUser = await TwitterDataProxy.validateUser(account, quest); + if (!validationResultUser.result) return validationResultUser; + const validationResultRepost = await TwitterDataProxy.validateRetweet(account, quest); + if (!validationResultRepost.result) return validationResultRepost; + }, + [QuestSocialRequirement.TwitterLikeRetweet]: async (account, quest) => { + logger.info(`[${quest.poolId}][${account.sub}] X Quest ${quest._id} LikeRepost verification started`); + + const validationResultUser = await TwitterDataProxy.validateUser(account, quest); + if (!validationResultUser.result) return validationResultUser; + const validationResultLike = await TwitterDataProxy.validateLike(account, quest); + if (!validationResultLike.result) return validationResultLike; + const validationResultRepost = await TwitterDataProxy.validateRetweet(account, quest); + if (!validationResultRepost.result) return validationResultRepost; + }, + [QuestSocialRequirement.TwitterFollow]: async (account, quest) => { + logger.info(`[${quest.poolId}][${account.sub}] X Quest ${quest._id} Follow verification started`); + + const resultUser = await TwitterDataProxy.validateUser(account, quest); + if (!resultUser.result) return resultUser; + const validationResultFollow = await TwitterDataProxy.validateFollow(account, quest.content); + if (!validationResultFollow.result) return validationResultFollow; + }, + [QuestSocialRequirement.TwitterQuery]: async (account, quest) => { + logger.info(`[${quest.poolId}][${account.sub}] X Quest ${quest._id} Message verification started`); + const resultUser = await TwitterDataProxy.validateUser(account, quest); + if (!resultUser.result) return resultUser; + const validationResultMessage = await TwitterDataProxy.validateQuery(account, quest); + if (!validationResultMessage.result) return validationResultMessage; + }, + [QuestSocialRequirement.DiscordGuildJoined]: async (account, quest) => { + return await DiscordDataProxy.validateGuildJoined(account, quest.content); + }, + [QuestSocialRequirement.DiscordGuildRole]: async (account, quest) => { + const { roleId } = JSON.parse(quest.contentMetadata); + return await DiscordDataProxy.validateGuildRole(account, quest.content, roleId); + }, + [QuestSocialRequirement.DiscordMessage]: async (account, quest) => { + return { result: true, reason: '' }; + }, + [QuestSocialRequirement.DiscordMessageReaction]: async (account, quest) => { + return { result: true, reason: '' }; + }, +}; + +export const tokenInteractionMap: { [interaction: number]: { kind: AccessTokenKind; scopes: OAuthScope[] } } = { + [QuestSocialRequirement.YouTubeLike]: { + kind: AccessTokenKind.Google, + scopes: OAuthRequiredScopes.GoogleYoutubeLike, + }, + [QuestSocialRequirement.YouTubeSubscribe]: { + kind: AccessTokenKind.Google, + scopes: OAuthRequiredScopes.GoogleYoutubeSubscribe, + }, + [QuestSocialRequirement.TwitterLike]: { + kind: AccessTokenKind.Twitter, + scopes: OAuthRequiredScopes.TwitterValidateLike, + }, + [QuestSocialRequirement.TwitterRetweet]: { + kind: AccessTokenKind.Twitter, + scopes: OAuthRequiredScopes.TwitterValidateRepost, + }, + [QuestSocialRequirement.TwitterFollow]: { + kind: AccessTokenKind.Twitter, + scopes: OAuthRequiredScopes.TwitterValidateFollow, + }, + [QuestSocialRequirement.TwitterQuery]: { kind: AccessTokenKind.Twitter, scopes: OAuthRequiredScopes.TwitterAuth }, + [QuestSocialRequirement.TwitterLikeRetweet]: { + kind: AccessTokenKind.Twitter, + scopes: OAuthRequiredScopes.TwitterValidateLike, + }, + [QuestSocialRequirement.DiscordGuildJoined]: { + kind: AccessTokenKind.Discord, + scopes: OAuthRequiredScopes.DiscordValidateGuild, + }, + [QuestSocialRequirement.DiscordGuildRole]: { + kind: AccessTokenKind.Discord, + scopes: OAuthRequiredScopes.DiscordAuth, + }, + [QuestSocialRequirement.DiscordMessage]: { + kind: AccessTokenKind.Discord, + scopes: OAuthRequiredScopes.DiscordAuth, + }, + [QuestSocialRequirement.DiscordMessageReaction]: { + kind: AccessTokenKind.Discord, + scopes: OAuthRequiredScopes.DiscordAuth, + }, +}; diff --git a/apps/api/src/app/types/augment-express-request.d.ts b/apps/api/src/app/types/augment-express-request.d.ts new file mode 100644 index 000000000..de7385dff --- /dev/null +++ b/apps/api/src/app/types/augment-express-request.d.ts @@ -0,0 +1,11 @@ +namespace Express { + interface Request { + origin?: string; + auth?: any; + rawBody?: string; + account?: TAccount; + wallet?: WalletDocument; + campaign?: PoolDocument; + quest?: TQuest; + } +} diff --git a/apps/api/src/app/types/cors.d.ts b/apps/api/src/app/types/cors.d.ts new file mode 100644 index 000000000..8524732d2 --- /dev/null +++ b/apps/api/src/app/types/cors.d.ts @@ -0,0 +1 @@ +declare module 'cors'; diff --git a/apps/api/src/app/types/jsonwebtoken.d.ts b/apps/api/src/app/types/jsonwebtoken.d.ts new file mode 100644 index 000000000..0493c0083 --- /dev/null +++ b/apps/api/src/app/types/jsonwebtoken.d.ts @@ -0,0 +1 @@ +declare module 'jsonwebtoken'; diff --git a/apps/api/src/app/types/migrate-mongo.d.ts b/apps/api/src/app/types/migrate-mongo.d.ts new file mode 100644 index 000000000..4e6e50f6d --- /dev/null +++ b/apps/api/src/app/types/migrate-mongo.d.ts @@ -0,0 +1,110 @@ +// Copied type definitions for migrate-mongo here since the @types/migrate-mongo +// lags behind the current version. Once @types/migrate-mongo@^8.2.3 is available +// we can install that again and remove this file. +// +// Project: https://github.com/seppevs/migrate-mongo#readme +// Definitions by: Amit Beckenstein +// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped +// Minimum TypeScript Version: 3.2 + +import * as mongo from 'mongodb'; +declare module 'migrate-mongo' { + export function init(): Promise; + export function create(description: string): Promise; + export namespace database { + function connect(): Promise<{ + client: mongo.MongoClient; + db: mongo.Db & { close: mongo.MongoClient['close'] }; + }>; + } + export namespace config { + /** + * @internal + */ + const DEFAULT_CONFIG_FILE_NAME: string; + /** + * @internal + */ + function shouldExist(): Promise; + /** + * @internal + */ + function shouldNotExist(): Promise; + /** + * @internal + */ + function getConfigFilename(): string; + /** + * Read the `migrate-mongo-config.js` file. + */ + function read(): Promise; + /** + * Set the passed config object. + * @param config The config object. + */ + function set(config: Partial): void; + + interface Config { + mongodb: { + url: Parameters[0]; + databaseName?: mongo.Db['databaseName']; + options?: mongo.MongoClientOptions; + }; + /** + * The migrations dir, can be an relative or absolute path. + */ + migrationsDir?: string | undefined; + /** + * The MongoDB collection where the applied changes are stored. + */ + changelogCollectionName: string; + } + } + + /** + * Apply all pending migrations. + * @example + * ```js + * const db = await database.connect(); + * const migrated = await up(db); + * migrated.forEach(fileName => console.log('Migrated:', fileName)); + * ``` + * If an an error occurred, the promise will reject and won't continue with the rest of the pending migrations. + */ + export function up(db: mongo.Db, client: mongo.MongoClient): Promise; + /** + * Revert (only) the last applied migration. + * @example + * ```js + * const db = await database.connect(); + * const migratedDown = await down(db); + * migratedDown.forEach(fileName => console.log('Migrated Down:', fileName)); + * ``` + */ + export function down(db: mongo.Db, client: mongo.MongoClient): Promise; + export function status(db: mongo.Db): Promise; + + export interface MigrationStatus { + fileName: string; + /** + * Either "PENDING" or a JSON date. + */ + appliedAt: string; + } + + /** + * Type of `up()` and `down()` functions for migration scripts. + */ + export type MigrationFunction = + | ((db: mongo.Db, client: mongo.MongoClient) => Promise) + /** + * @deprecated Callbacks are supported for backwards compatibility. + * New migration scripts should be written using `Promise`s and/or `async` & `await`. It's easier to read and write. + */ + | ((db: mongo.Db, next: mongo.MongoCallback) => void) + /** + * @deprecated Callbacks are supported for backwards compatibility. + * New migration scripts should be written using `Promise`s and/or `async` & `await`. It's easier to read and write. + */ + | ((db: mongo.Db, client: mongo.MongoClient, next: mongo.MongoCallback) => void); +} diff --git a/apps/api/src/app/types/morgan-json.d.ts b/apps/api/src/app/types/morgan-json.d.ts new file mode 100644 index 000000000..2b861c8c3 --- /dev/null +++ b/apps/api/src/app/types/morgan-json.d.ts @@ -0,0 +1 @@ +declare module 'morgan-json'; diff --git a/apps/api/src/app/types/morgan.d.ts b/apps/api/src/app/types/morgan.d.ts new file mode 100644 index 000000000..0b6637ede --- /dev/null +++ b/apps/api/src/app/types/morgan.d.ts @@ -0,0 +1 @@ +declare module 'morgan'; diff --git a/apps/api/src/app/types/swagger-autogen.d.ts b/apps/api/src/app/types/swagger-autogen.d.ts new file mode 100644 index 000000000..061dbe38b --- /dev/null +++ b/apps/api/src/app/types/swagger-autogen.d.ts @@ -0,0 +1 @@ +declare module 'swagger-autogen'; diff --git a/apps/api/src/app/types/swagger-ui-express.d.ts b/apps/api/src/app/types/swagger-ui-express.d.ts new file mode 100644 index 000000000..d7910fe9e --- /dev/null +++ b/apps/api/src/app/types/swagger-ui-express.d.ts @@ -0,0 +1 @@ +declare module 'swagger-ui-express'; diff --git a/apps/api/src/app/util/agenda.ts b/apps/api/src/app/util/agenda.ts new file mode 100644 index 000000000..c1ba701f2 --- /dev/null +++ b/apps/api/src/app/util/agenda.ts @@ -0,0 +1,64 @@ +import db from './database'; +import { Agenda, Job } from '@hokify/agenda'; +import { updatePendingTransactions } from '@thxnetwork/api/jobs/updatePendingTransactions'; +import { sendPoolAnalyticsReport } from '@thxnetwork/api/jobs/sendPoolAnalyticsReport'; +import { updateCampaignRanks } from '@thxnetwork/api/jobs/updateCampaignRanks'; +import { updateParticipantRanks } from '@thxnetwork/api/jobs/updateParticipantRanks'; +import { logger } from './logger'; +import { MONGODB_URI } from '../config/secrets'; +import { JobType } from '@thxnetwork/common/enums'; +import SafeService from '@thxnetwork/api/services/SafeService'; +import WebhookService from '../services/WebhookService'; +import QuestService from '../services/QuestService'; +import TwitterCacheService from '../services/TwitterCacheService'; +import InvoiceService from '../services/InvoiceService'; +import BalancerService from '../services/BalancerService'; +import RewardService from '../services/RewardService'; +import PaymentService from '../services/PaymentService'; +import TwitterQueryService from '../services/TwitterQueryService'; +import VoteEscrowService from '../services/VoteEscrowService'; + +const agenda = new Agenda({ + db: { + address: MONGODB_URI, + collection: 'jobs', + }, + maxConcurrency: 1, + lockLimit: 1, + processEvery: '1 second', +}); + +agenda.define(JobType.UpdateCampaignRanks, updateCampaignRanks); +agenda.define(JobType.UpdateParticipantRanks, updateParticipantRanks); +agenda.define(JobType.UpdatePendingTransactions, updatePendingTransactions); +agenda.define(JobType.CreateTwitterQuests, () => TwitterQueryService.searchJob()); +agenda.define(JobType.CreateQuestEntry, (job: Job) => QuestService.createEntryJob(job)); +agenda.define(JobType.CreateRewardPayment, (job: Job) => RewardService.createPaymentJob(job)); +agenda.define(JobType.DeploySafe, (job: Job) => SafeService.createJob(job)); +agenda.define(JobType.SendCampaignReport, sendPoolAnalyticsReport); +agenda.define(JobType.RequestAttemp, (job: Job) => WebhookService.requestAttemptJob(job)); +agenda.define(JobType.UpdateTwitterLikeCache, (job: Job) => TwitterCacheService.updateLikeCacheJob(job)); +agenda.define(JobType.UpdateTwitterRepostCache, (job: Job) => TwitterCacheService.updateRepostCacheJob(job)); +agenda.define(JobType.UpsertInvoices, () => InvoiceService.upsertJob()); +agenda.define(JobType.UpdatePrices, () => BalancerService.updatePricesJob()); +agenda.define(JobType.UpdateAPR, () => BalancerService.updateMetricsJob()); +agenda.define(JobType.AssertPayments, () => PaymentService.assertPaymentsJob()); +agenda.define(JobType.ClaimExternalRewards, () => VoteEscrowService.claimExternalRewardsJob()); + +db.connection.once('open', async () => { + await agenda.start(); + + await agenda.every('10 seconds', JobType.UpdatePrices); + await agenda.every('10 seconds', JobType.UpdatePendingTransactions); + await agenda.every('5 minutes', JobType.UpdateCampaignRanks); + await agenda.every('15 minutes', JobType.UpsertInvoices); + await agenda.every('15 minutes', JobType.UpdateAPR); + await agenda.every('1 day', JobType.CreateTwitterQuests); + await agenda.every('1 day', JobType.AssertPayments); + await agenda.every('1 day', JobType.ClaimExternalRewards); + await agenda.every('0 9 * * MON', JobType.SendCampaignReport); + + logger.info('AgendaJS started job processor'); +}); + +export { agenda, JobType }; diff --git a/apps/api/src/app/util/alchemy.ts b/apps/api/src/app/util/alchemy.ts new file mode 100644 index 000000000..1f2c20387 --- /dev/null +++ b/apps/api/src/app/util/alchemy.ts @@ -0,0 +1,48 @@ +import { Network, Alchemy, OwnedNft } from 'alchemy-sdk'; +import { ALCHEMY_API_KEY } from '../config/secrets'; +import { logger } from './logger'; +import { IPFS_BASE_URL } from '@thxnetwork/api/config/secrets'; + +export const alchemy = new Alchemy({ + apiKey: ALCHEMY_API_KEY, + network: Network.MATIC_MAINNET, +}); + +export async function getNFTsForOwner(owner: string, contractAddress: string) { + const pageSize = 100; + let pageKey = 0, + pageCount = 1, + ownedNfts: OwnedNft[] = []; + + while (pageKey < pageCount) { + try { + const key = String(++pageKey); + const result = await alchemy.nft.getNftsForOwner(owner, { + contractAddresses: [contractAddress], + omitMetadata: false, + pageSize, + pageKey: key, + }); + const totalCount = Number(result.totalCount); + + // If total is less than size there will only be 1 page, if not round up total / size + // to get the max amount of pages + pageCount = totalCount < pageSize ? 1 : Math.ceil(totalCount / pageSize); + + ownedNfts = ownedNfts.concat(result.ownedNfts); + } catch (error) { + logger.error(error); + } + } + + return ownedNfts; +} + +export function parseIPFSImageUrl(url = 'ipfs://QmdnhWN8VjX45BfX7sJuUnwcr7HU9YcDPPLCPQhSuuyjZ3/0.png') { + const ipfsPrefix = 'ipfs://'; + if (url.startsWith(ipfsPrefix)) { + const ipfsPath = url.substring(ipfsPrefix.length); + return IPFS_BASE_URL + ipfsPath; + } + return url; +} diff --git a/apps/api/src/app/util/auth.ts b/apps/api/src/app/util/auth.ts new file mode 100644 index 000000000..af39da296 --- /dev/null +++ b/apps/api/src/app/util/auth.ts @@ -0,0 +1,52 @@ +import axios, { AxiosRequestConfig } from 'axios'; +import { THXError } from './errors'; +import { AUTH_CLIENT_ID, AUTH_CLIENT_SECRET, AUTH_URL } from '@thxnetwork/api/config/secrets'; +import { AxiosResponse } from 'axios'; + +class AuthAccesTokenRequestError extends THXError { + message = 'Auth access token request failed'; +} + +let authAccessToken = ''; +let authAccessTokenExpires = 0; + +export const authClient = async (options: AxiosRequestConfig) => { + try { + axios.defaults.baseURL = AUTH_URL; + return await axios(options); + } catch (error) { + if (error && error.response && error.response.status >= 400 && error.response.status <= 600) { + return error.response as AxiosResponse; + } + } +}; + +async function requestAuthAccessToken() { + const data = new URLSearchParams(); + data.append('grant_type', 'client_credentials'); + data.append('scope', 'openid accounts:read accounts:write'); + + const r = await authClient({ + url: '/token', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Basic ' + Buffer.from(`${AUTH_CLIENT_ID}:${AUTH_CLIENT_SECRET}`).toString('base64'), + }, + data, + }); + + if (r.status !== 200) throw new AuthAccesTokenRequestError(); + + return r.data; +} + +export async function getAuthAccessToken() { + if (!authAccessTokenExpires || Date.now() > authAccessTokenExpires) { + const { access_token, expires_in } = await requestAuthAccessToken(); + authAccessToken = access_token; + authAccessTokenExpires = Date.now() + expires_in * 1000; + } + + return `Bearer ${authAccessToken}`; +} diff --git a/apps/api/src/app/util/code.ts b/apps/api/src/app/util/code.ts new file mode 100644 index 000000000..ec31c91bd --- /dev/null +++ b/apps/api/src/app/util/code.ts @@ -0,0 +1,12 @@ +import { ChainId } from '@thxnetwork/common/enums'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { NoDataAtAddressError } from '@thxnetwork/api/util/errors'; + +export async function getCodeForAddressOnNetwork(address: string, chainId: ChainId) { + const { web3 } = getProvider(chainId); + const code = await web3.eth.getCode(address); + + if (code === '0x') { + throw new NoDataAtAddressError(address); + } +} diff --git a/apps/api/src/app/util/condition.ts b/apps/api/src/app/util/condition.ts new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/src/app/util/database.ts b/apps/api/src/app/util/database.ts new file mode 100644 index 000000000..d4192af88 --- /dev/null +++ b/apps/api/src/app/util/database.ts @@ -0,0 +1,59 @@ +import mongoose from 'mongoose'; +import { logger } from './logger'; +import { v4 } from 'uuid'; + +const connect = async (url: string) => { + mongoose.connection.on('error', (err) => { + logger.error(`MongoDB connection error. Please make sure MongoDB is running. ${err}`); + }); + + mongoose.connection.on('reconnectFailed', () => { + logger.error('Unable to reconnect to MongoDB'); + process.exit(); + }); + + mongoose.connection.on('open', () => { + logger.info(`MongoDB successfully connected to ${url.split('@')[1]}`); + }); + + mongoose.connection.on('close', () => { + logger.info(`MongoDB successfully closed connection`); + }); + + if (mongoose.connection.readyState === 0) { + await mongoose.connect(url); + } +}; + +const truncate = async () => { + if (mongoose.connection.readyState !== 0) { + const { collections } = mongoose.connection; + const promises = Object.keys(collections).map((collection) => { + return mongoose.connection.collection(collection).deleteMany({}); + }); + await Promise.all(promises); + } +}; + +const readyState = () => { + return mongoose.connection.readyState; +}; + +const disconnect = async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } +}; + +const createUUID = () => { + return v4(); +}; + +export default { + connect, + truncate, + disconnect, + readyState, + connection: mongoose.connection, + createUUID, +}; diff --git a/apps/api/src/app/util/date.ts b/apps/api/src/app/util/date.ts new file mode 100644 index 000000000..c4a1c04f1 --- /dev/null +++ b/apps/api/src/app/util/date.ts @@ -0,0 +1,18 @@ +export function addMinutes(date: Date, minutes: number) { + return new Date(date.getTime() + minutes * 60000); +} + +export function subMinutes(date: Date, minutes: number) { + return new Date(date.getTime() - minutes * 60000); +} + +export function formatDate(date: Date) { + const yyyy = date.getFullYear(); + let mm: any = date.getMonth() + 1; // Months start at 0! + let dd: any = date.getDate(); + + if (dd < 10) dd = '0' + dd; + if (mm < 10) mm = '0' + mm; + + return yyyy + '-' + mm + '-' + dd; +} diff --git a/apps/api/src/app/util/dictionaries.ts b/apps/api/src/app/util/dictionaries.ts new file mode 100644 index 000000000..456c775fc --- /dev/null +++ b/apps/api/src/app/util/dictionaries.ts @@ -0,0 +1,52 @@ +export const celebratoryWords = [ + 'Huray!', + 'Yay!', + 'Whoop!', + 'Hoorah!', + 'Woo-hoo!', + 'Yippee!', + 'Bravo!', + 'Cheers!', + 'Yee-haw!', + 'Hip-hip-hooray!', + 'Awesome!', + 'Fantastic!', + 'Celebrate!', + 'Triumph!', + 'Victory!', + 'Delight!', + 'Excelsior!', + 'Amazing!', + 'Outstanding!', + 'Ecstatic!', + 'Thrilling!', + 'Glorious!', + 'Exhilarating!', + 'Jubilation!', + 'Eureka!', + 'Huzzah!', + 'Applause!', + 'Delirious!', + 'Phenomenal!', + 'Splendid!', + 'Magnificent!', + 'Terrific!', + 'Superb!', + 'Incredible!', + 'Astounding!', + 'Majestic!', + 'Wonderful!', + 'Marvelous!', + 'Electrifying!', + 'Fabulous!', + 'Glittering!', + 'Splendiferous!', + 'Stupendous!', + 'Ecstasy!', + 'Triumphant!', + 'Brilliant!', + 'Radiant!', + 'Euphoria!', + 'Unbelievable!', + 'Spectacular!', +]; diff --git a/apps/api/src/app/util/discord.ts b/apps/api/src/app/util/discord.ts new file mode 100644 index 000000000..9295f557d --- /dev/null +++ b/apps/api/src/app/util/discord.ts @@ -0,0 +1,33 @@ +import { Client, SlashCommandBuilder } from 'discord.js'; +import { REST, Routes } from 'discord.js'; +import { DISCORD_CLIENT_ID, BOT_TOKEN } from '../config/secrets'; +import { logger } from './logger'; +import { onAutoComplete } from '../events/InteractionCreated'; +import { Events } from 'discord.js'; + +const rest = new REST({ version: '10' }).setToken(BOT_TOKEN); + +export const commandRegister = async (commandList: SlashCommandBuilder[]) => { + try { + const commands = commandList.map((cmd) => cmd.toJSON()); + await rest.put(Routes.applicationCommands(DISCORD_CLIENT_ID), { body: commands }); + logger.info(`Successfully reloaded ${commands.length} application (/) commands.`); + } catch (error) { + logger.error(`Some error happened while loading application (/) commands.`); + logger.error(error); + } +}; + +export const eventRegister = (client: Client, router: { [key: string]: any }) => { + Object.keys(router).forEach((key) => { + client.on(key, router[key]); + }); + client.on(Events.InteractionCreate, onAutoComplete); + client.on('error', (error) => { + console.error('Discord.js error:', error); + }); +}; + +export function discordColorToHex(discordColorCode) { + return `#${discordColorCode.toString(16).padStart(6, '0')}`; +} diff --git a/apps/api/src/app/util/errors.ts b/apps/api/src/app/util/errors.ts new file mode 100644 index 000000000..ca7302449 --- /dev/null +++ b/apps/api/src/app/util/errors.ts @@ -0,0 +1,186 @@ +class THXError extends Error { + message: string; + + constructor(message?: string) { + super(message); + this.name = this.constructor.name; + Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain + } +} + +class NoUserFound extends THXError { + constructor() { + super('Could not find a user for this address'); + } +} + +class NotAMemberError extends THXError { + constructor(address: string, assetPool: string) { + super(`${address} is not a member of assetPool ${assetPool}`); + } +} +class AlreadyAMemberError extends THXError { + constructor(address: string, assetPool: string) { + super(`${address} is already a member of assetPool ${assetPool}`); + } +} + +class NoDataAtAddressError extends THXError { + constructor(address: string) { + super(`No data found at address ${address}`); + } +} + +class THXHttpError extends THXError { + status: number; + constructor(message?: string, status?: number) { + super(message); + if (status) { + this.status = status; + } + } +} + +class BadRequestError extends THXHttpError { + status = 400; + constructor(message?: string) { + super(message || 'Bad Request'); + } +} + +class UnauthorizedError extends THXHttpError { + status = 401; + constructor(message?: string) { + super(message || 'Unauthorized'); + } +} + +class ForbiddenError extends THXHttpError { + status = 403; + constructor(message?: string) { + super(message || 'Forbidden'); + } +} + +class NotFoundError extends THXHttpError { + status = 404; + constructor(message?: string) { + super(message || 'Not Found'); + } +} + +class ConflictError extends THXHttpError { + status = 409; + constructor(message?: string) { + super(message || 'Conflict'); + } +} + +class InternalServerError extends THXHttpError { + status = 500; + constructor(message?: string) { + super(message || 'Internal Server Error'); + } +} + +class NotImplementedError extends THXHttpError { + status = 501; + constructor(message?: string) { + super(message || 'Not Implemented'); + } +} + +class BadGatewayError extends THXHttpError { + status = 502; + constructor(message?: string) { + super(message || 'Bad Gateway'); + } +} + +class PromoCodeNotFoundError extends NotFoundError { + message = 'Could not find this promo code'; +} + +class SubjectUnauthorizedError extends ForbiddenError { + message = 'Not authorized for subject of access token'; +} + +class AudienceUnauthorizedError extends UnauthorizedError { + message = 'Not authorized for audience of access token'; +} + +class AmountExceedsAllowanceError extends BadRequestError { + message = 'Transfer amount exceeds allowance'; +} + +class InsufficientBalanceError extends BadRequestError { + message = 'Transfer amount exceeds balance'; +} + +class InsufficientAllowanceError extends BadRequestError { + message = 'Requested amount exceeds allowance'; +} + +class TokenPaymentFailedError extends InternalServerError { + message = 'Transfer did not succeed'; +} +class GetPastTransferEventsError extends InternalServerError { + message = 'GetPastEvents for Transfer event failed in callback.'; +} +class GetPastWithdrawnEventsError extends InternalServerError { + message = 'GetPastEvents for Withdrawn event failed in callback.'; +} + +class DuplicateEmailError extends BadRequestError { + message = 'An account with this e-mail address already exists.'; +} + +class GetPastWithdrawPollCreatedEventsError extends InternalServerError { + message = 'GetPastEvents for WithdrawPollCreated event failed in callback.'; +} +class MaxFeePerGasExceededError extends THXError { + message = 'MaxFeePerGas from oracle exceeds configured cap'; +} +class NoFeeDataError extends THXError { + message = 'Could not get fee data from oracle'; +} + +class DiscordDisconnected extends THXError { + message = 'Please sign in to your THX account and connect your Discord account.'; +} + +class DiscordSafeNotFound extends THXError { + message = 'Please sign in to your THX account so we can deploy your Safe multisig wallet.'; +} + +export { + DiscordSafeNotFound, + DiscordDisconnected, + THXError, + NoUserFound, + THXHttpError, + BadRequestError, + UnauthorizedError, + ForbiddenError, + NotFoundError, + ConflictError, + NotImplementedError, + BadGatewayError, + InternalServerError, + PromoCodeNotFoundError, + SubjectUnauthorizedError, + AudienceUnauthorizedError, + AmountExceedsAllowanceError, + InsufficientBalanceError, + TokenPaymentFailedError, + GetPastTransferEventsError, + GetPastWithdrawnEventsError, + DuplicateEmailError, + GetPastWithdrawPollCreatedEventsError, + NotAMemberError, + AlreadyAMemberError, + NoDataAtAddressError, + MaxFeePerGasExceededError, + InsufficientAllowanceError, + NoFeeDataError, +}; diff --git a/apps/api/src/app/util/events.ts b/apps/api/src/app/util/events.ts new file mode 100644 index 000000000..8946815f2 --- /dev/null +++ b/apps/api/src/app/util/events.ts @@ -0,0 +1,74 @@ +import { ethers } from 'ethers'; +import { THXError } from './errors'; +import { logger } from './logger'; + +export class ExpectedEventNotFound extends THXError { + constructor(event: string) { + super(`Event ${event} expected in eventlog but not found.`); + } +} + +export function parseArgs(args: any) { + const returnValues: any = {}; + for (const key of Object.keys(args)) { + if (isNaN(Number(key))) { + returnValues[key] = args[key]; + } + } + return returnValues; +} + +export function parseLog(abi: any, log: any) { + const contractInterface = new ethers.utils.Interface(abi); + try { + return contractInterface.parseLog(log); + } catch (e) { + logger.error(e.toString()); + return; + } +} + +export function hex2a(hex: any) { + let str = ''; + for (let i = 0; i < hex.length; i += 2) { + const v = parseInt(hex.substr(i, 2), 16); + if (v == 8) continue; // http://www.fileformat.info/info/unicode/char/0008/index.htm + if (v == 15) continue; + if (v == 16) continue; // http://www.fileformat.info/info/unicode/char/0010/index.htm + if (v == 14) continue; // https://www.fileformat.info/info/unicode/char/000e/index.htm + if (v) str += String.fromCharCode(v); + } + return str.trim(); +} + +export function findEvent(eventName: string, events: CustomEventLog[]): CustomEventLog { + return events.find((ev: any) => ev && ev.name === eventName); +} + +export function assertEvent(eventName: string, events: CustomEventLog[]): CustomEventLog { + const event = findEvent(eventName, events); + + if (!event) { + throw new ExpectedEventNotFound(eventName); + } + + return event; +} + +export interface CustomEventLog { + name: string; + args: any; + blockNumber: number; + transactionHash: string; +} + +export function parseLogs(abi: any, logs: any = []): CustomEventLog[] { + const contractInterface = new ethers.utils.Interface(abi); + return logs.map((log: any) => { + try { + return { ...log, ...contractInterface.parseLog(log) }; + } catch (e) { + return; + } + }); +} diff --git a/apps/api/src/app/util/galachain.ts b/apps/api/src/app/util/galachain.ts new file mode 100644 index 000000000..1f6593422 --- /dev/null +++ b/apps/api/src/app/util/galachain.ts @@ -0,0 +1,48 @@ +import path from 'path'; +import { CWD } from '../config/secrets'; +import { gcclient, HFClientConfig } from '@gala-chain/client'; + +enum GalachainRole { + Partner = 0, + Curator = 1, + User = 2, +} + +enum GalachainContract { + PublicKeyContract = 'PublicKeyContract', + GalaChainToken = 'GalaChainToken', +} + +const credentials: { [role: number]: HFClientConfig } = { + [GalachainRole.Partner]: { + orgMsp: 'PartnerOrg', + userId: 'admin', + userSecret: 'adminpw', + connectionProfilePath: path.resolve(CWD, 'app/connection-profiles/cpp-partner.json'), + }, + [GalachainRole.Curator]: { + orgMsp: 'CuratorOrg', + userId: 'admin', + userSecret: 'adminpw', + connectionProfilePath: path.resolve(CWD, 'app/connection-profiles/cpp-curator.json'), + }, + [GalachainRole.User]: { + orgMsp: 'UserOrg', + userId: 'admin', + userSecret: 'adminpw', + connectionProfilePath: path.resolve(CWD, 'app/connection-profiles/cpp-user.json'), + }, +}; + +const getClient = (role: GalachainRole) => { + const params = credentials[role]; + return gcclient.forConnectionProfile(params); +}; + +const getContract = (variant: GalachainContract) => ({ + channelName: 'product-channel', + chaincodeName: 'basic-product', + contractName: variant, +}); + +export { getContract, getClient, GalachainRole, GalachainContract }; diff --git a/apps/api/src/app/util/healthcheck.ts b/apps/api/src/app/util/healthcheck.ts new file mode 100644 index 000000000..12bdd0bcd --- /dev/null +++ b/apps/api/src/app/util/healthcheck.ts @@ -0,0 +1,34 @@ +import newrelic from 'newrelic'; +import { config, status } from 'migrate-mongo'; +import { connection } from 'mongoose'; +import { HealthCheck } from '@godaddy/terminus'; + +import migrateMongoConfig from '../config/migrate-mongo'; + +const dbConnected = async () => { + // https://mongoosejs.com/docs/api.html#connection_Connection-readyState + const { readyState } = connection; + // ERR_CONNECTING_TO_MONGO + if (readyState === 0 || readyState === 3) { + throw new Error('Mongoose has disconnected'); + } + // CONNECTING_TO_MONGO + if (readyState === 2) { + throw new Error('Mongoose is connecting'); + } +}; + +const migrationsApplied = async () => { + config.set(migrateMongoConfig); + const pendingMigrations = (await status(connection.db as any)).filter( + (migration) => migration.appliedAt === 'PENDING', + ); + if (pendingMigrations.length > 0) { + throw new Error('Not all migrations applied'); + } +}; + +export const healthCheck: HealthCheck = async () => { + newrelic.getTransaction().ignore(); + return Promise.all([dbConnected(), migrationsApplied()]); +}; diff --git a/apps/api/src/app/util/helpers.ts b/apps/api/src/app/util/helpers.ts new file mode 100644 index 000000000..788236765 --- /dev/null +++ b/apps/api/src/app/util/helpers.ts @@ -0,0 +1,23 @@ +export const pick = (object: T, keys: K[]): Pick => { + return Object.assign( + {}, + ...keys.map((key) => { + if (object && Object.prototype.hasOwnProperty.call(object, key)) { + return { [key]: object[key] }; + } + }), + ); +}; + +export const uniq = (array: T[]): T[] => { + return [...new Set(array)]; +}; + +export const sleep = async (seconds: number) => { + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +}; + +export const convertObjectIdToNumber = (objectId: string) => { + // Extract the timestamp part of the ObjectId (first 4 bytes) + return parseInt(objectId.toString().substring(0, 8), 16); +}; diff --git a/apps/api/src/app/util/index.ts b/apps/api/src/app/util/index.ts new file mode 100644 index 000000000..c5f595cf9 --- /dev/null +++ b/apps/api/src/app/util/index.ts @@ -0,0 +1 @@ +export * from './helpers'; diff --git a/apps/api/src/app/util/ip.ts b/apps/api/src/app/util/ip.ts new file mode 100644 index 000000000..ae0b16e77 --- /dev/null +++ b/apps/api/src/app/util/ip.ts @@ -0,0 +1,7 @@ +import { Request } from 'express'; + +function getIP(req: Request) { + return req.ip || req.header('x-forwarded-for'); +} + +export { getIP }; diff --git a/apps/api/src/app/util/jest/config.ts b/apps/api/src/app/util/jest/config.ts new file mode 100644 index 000000000..278617aae --- /dev/null +++ b/apps/api/src/app/util/jest/config.ts @@ -0,0 +1,91 @@ +import db from '@thxnetwork/api/util/database'; +import { mockStart } from './mock'; +import { safeVersion } from '@thxnetwork/api/services/ContractService'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { ChainId, WalletVariant } from '@thxnetwork/common/enums'; +import { + sub, + sub2, + sub3, + sub4, + userWalletAddress, + userWalletAddress2, + userWalletAddress3, + userWalletAddress4, +} from './constants'; +import { Wallet } from '@thxnetwork/api/models'; +import Safe, { SafeFactory } from '@safe-global/protocol-kit'; +import { contractNetworks } from '@thxnetwork/api/contracts'; +import { poll } from '../polling'; +import { agenda } from '../agenda'; + +export async function beforeAllCallback(options = { skipWalletCreation: false }) { + mockStart(); + + const { web3, defaultAccount, ethAdapter } = getProvider(ChainId.Hardhat); + // Wait for this hardhat log: + const lastDeployedContractAddress = '0x726c9c8278ec209cfbe6bbb1d02e65df6fcb7cda'; + const fn = () => web3.eth.getCode(lastDeployedContractAddress); + const fnCondition = (result: string) => result === '0x'; + + await poll(fn, fnCondition, 500); + + if (!options.skipWalletCreation) { + const chainId = ChainId.Hardhat; + const safeFactory = await SafeFactory.create({ + safeVersion, + ethAdapter, + contractNetworks, + }); + for (const entry of [ + { sub, userWalletAddress }, + { sub: sub2, userWalletAddress: userWalletAddress2 }, + { sub: sub3, userWalletAddress: userWalletAddress3 }, + ]) { + const safeAccountConfig = { + owners: [defaultAccount, entry.userWalletAddress], + threshold: 2, + }; + const safeAddress = await safeFactory.predictSafeAddress(safeAccountConfig); + + await Wallet.create({ + sub: entry.sub, + safeVersion, + address: safeAddress, + chainId, + variant: WalletVariant.Safe, + }); + + try { + await Safe.create({ + ethAdapter, + safeAddress, + contractNetworks, + }); + } catch (error) { + await safeFactory.deploySafe({ safeAccountConfig, options: { gasLimit: '3000000' } }); + } + } + + // Create wallet for metamask account + await Wallet.create({ + chainId: ChainId.Hardhat, + sub: sub4, + address: userWalletAddress4, + variant: WalletVariant.WalletConnect, + }); + } +} + +export async function afterAllCallback() { + await new Promise((resolve) => { + // Listen for 'complete' event + agenda.on('complete', () => { + resolve(); + }); + }); + await agenda.stop(); + await agenda.cancel({}); + await agenda.purge(); + await db.truncate(); +} diff --git a/apps/api/src/app/util/jest/constants.ts b/apps/api/src/app/util/jest/constants.ts new file mode 100644 index 000000000..656144f62 --- /dev/null +++ b/apps/api/src/app/util/jest/constants.ts @@ -0,0 +1,89 @@ +import { AccountPlanType, AccountVariant } from '@thxnetwork/common/enums'; +import { getToken } from './jwt'; +import { toWei } from 'web3-utils'; +import { CYPRESS_EMAIL } from '@thxnetwork/api/config/secrets'; + +export const tokenName = 'Volunteers United'; +export const tokenSymbol = 'VUT'; +export const tokenTotalSupply = toWei('100000000'); +export const rewardWithdrawAmount = '1000'; +export const rewardWithdrawDuration = 60; +export const rewardWithdrawUnlockDate = '2022-04-20'; +export const COLLECTOR_PK = '0x794a8efb7e73278907197b0f65e1c32724810f0399e1a12feb1e6af6fb77dbff'; +export const VOTER_PK = '0x97093724e1748ebfa6aa2d2ec4ec68df8678423ab9a12eb2d27ddc74e35e5db9'; +export const DEPOSITOR_PK = '0x5a05e38394194379795422d2e8c1d33e90033d90defec4880174c39198f707e3'; +export const userEmail = CYPRESS_EMAIL; +export const userEmail2 = CYPRESS_EMAIL; +export const userPassword = 'mellonmellonmellon'; +export const userPassword2 = 'mellonmellonmellon'; +export const voterEmail = CYPRESS_EMAIL; +export const newAddress = '0x253cA584af3E458392982EF246066A6750Fa0735'; +export const MaxUint256 = '115792089237316195423570985008687907853269984665640564039457584007913129639935'; +export const sub = '6074cbdd1459355fae4b6a14'; +export const sub2 = '6074cbdd1459355fae4b6a15'; +export const sub3 = '6074cbdd1459355fae4b6a16'; +export const sub4 = '6074cbdd1459355fae4b6a17'; +export const userWalletAddress = '0x960911a62FdDf7BA84D0d3aD016EF7D15966F7Dc'; +export const userWalletAddress2 = '0xaf9d56684466fcFcEA0a2B7fC137AB864d642946'; +export const userWalletAddress3 = '0x861EFc0989DF42d793e3147214FfFcA4D124cAE8'; +export const userWalletAddress4 = '0x6e781b0af6204c3dc23b4a7fe049202125a4f849'; +export const userWalletPrivateKey = '0x794a8efb7e73278907197b0f65e1c32724810f0399e1a12feb1e6af6fb77dbff'; +export const userWalletPrivateKey2 = '0x97093724e1748ebfa6aa2d2ec4ec68df8678423ab9a12eb2d27ddc74e35e5db9'; +export const userWalletPrivateKey3 = '0x5a05e38394194379795422d2e8c1d33e90033d90defec4880174c39198f707e3'; +export const userWalletPrivateKey4 = '0x3b7fdd74a6c50a03d6e37f2d2c54e6fc73d67ff7d32858e55d80b2a5c9946b79'; + +export const account = { + sub, + plan: AccountPlanType.Lite, + email: CYPRESS_EMAIL, + address: userWalletAddress, + tokens: [], +}; +export const account2 = { + sub: sub2, + plan: AccountPlanType.Lite, + email: CYPRESS_EMAIL, + address: userWalletAddress2, + tokens: [], +}; + +export const account3 = { + sub: sub3, + plan: AccountPlanType.Lite, + variant: AccountVariant.EmailPassword, + address: userWalletAddress3, + tokens: [], +}; + +export const account4 = { + sub: sub4, + plan: AccountPlanType.Lite, + variant: AccountVariant.Metamask, + address: userWalletAddress4, +}; + +export const rewardId = 1; +export const requestUris = ['http://localhost:8080']; +export const redirectUris = ['http://localhost:8080']; +export const postLogoutRedirectUris = ['http://localhost:8080']; +export const clientId = 'xxxxxxx'; +export const clientSecret = 'xxxxxxxxxxxxxx'; +export const registrationAccessToken = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; + +export const authScopes = 'wallets:read wallets:write'; +export const dashboardScopes = + 'openid pools:read pools:write erc20:write erc20:read erc721:write erc721:read erc1155:write erc1155:read rewards:read rewards:write deposits:read deposits:write promotions:read promotions:write widgets:write widgets:read transactions:read swaprule:read swaprule:write claims:read erc20_rewards:read erc20_rewards:write erc721_rewards:read erc721_rewards:write referral_rewards:read referral_rewards:write pool_subscription:read custom_rewards:write custom_rewards:read'; +export const dashboardAccessToken = getToken(dashboardScopes); +export const dashboardAccessToken2 = getToken(dashboardScopes, sub2); +export const walletScopes = + 'openid rewards:read erc20:read erc721:read erc1155:read withdrawals:read withdrawals:write deposits:read deposits:write account:read account:write memberships:read memberships:write promotions:read payments:write payments:read relay:write transactions:read transactions:write swap:read swap:write swaprule:read claims:read wallets:read wallets:write erc20_rewards:read erc721_rewards:read referral_rewards:read point_balances:read'; +export const widgetScopes = + 'openid offline_access account:read account:write erc20:read erc721:read erc1155:read point_balances:read referral_rewards:read point_rewards:read wallets:read wallets:write pool_subscription:read pool_subscription:write claims:read'; +export const walletAccessToken = getToken(walletScopes); +export const walletAccessToken2 = getToken(walletScopes, sub); +export const walletAccessToken3 = getToken(walletScopes, sub2); +export const widgetAccessToken = getToken(widgetScopes, sub); +export const widgetAccessToken2 = getToken(widgetScopes, sub2); +export const widgetAccessToken3 = getToken(widgetScopes, sub3); +export const widgetAccessToken4 = getToken(widgetScopes, sub4); +export const authAccessToken = getToken(authScopes); diff --git a/apps/api/src/app/util/jest/erc1155.ts b/apps/api/src/app/util/jest/erc1155.ts new file mode 100644 index 000000000..5d8205f15 --- /dev/null +++ b/apps/api/src/app/util/jest/erc1155.ts @@ -0,0 +1,52 @@ +import { API_URL, MINIMUM_GAS_LIMIT, VERSION } from '@thxnetwork/api/config/secrets'; +import { getByteCodeForContractName, getContractFromName } from '@thxnetwork/api/services/ContractService'; +import { ChainId } from '@thxnetwork/common/enums'; +import { getProvider } from '../network'; + +export async function deployERC1155(chainId = ChainId.Hardhat) { + const { web3, defaultAccount } = getProvider(chainId); + const contractName = 'THX_ERC1155'; + const contract = getContractFromName(chainId, contractName); + const bytecode = getByteCodeForContractName(contractName); + const baseURL = `${API_URL}/${VERSION}/erc1155/metadata/{id}`; + const fn = contract.deploy({ + data: bytecode, + arguments: [baseURL, defaultAccount], + }); + const data = fn.encodeABI(); + const estimate = await fn.estimateGas({ from: defaultAccount }); + const gas = estimate < Number(MINIMUM_GAS_LIMIT) ? MINIMUM_GAS_LIMIT : estimate; + const receipt = await web3.eth.sendTransaction({ + from: defaultAccount, + to: null, + data, + gas, + }); + + contract.options.address = receipt.contractAddress; + + return contract; +} + +export const mockGetNftsForOwner = (contractAddress: string) => { + return { + ownedNfts: [ + { + contract: { + address: contractAddress, + }, + tokenId: '1', + tokenUri: { + raw: 'https://ipfs.io/ipfs/QmRvCinGkzqDdmSZ3PzQRyHbQVqaFLTDyfyMMD54Bwcjsi', + }, + rawMetadata: { + name: '#1', + description: 'image description piece #1', + image: 'https://gateway.pinata.cloud/ipfs/QmemtAVJMkfUj3bAXee1H7vccbX6nC6Vbkbu6gBjdn1Kdh/1.png', + }, + }, + ], + pageKey: 1, + totalCount: 1, + }; +}; diff --git a/apps/api/src/app/util/jest/erc721.ts b/apps/api/src/app/util/jest/erc721.ts new file mode 100644 index 000000000..9f8ac5681 --- /dev/null +++ b/apps/api/src/app/util/jest/erc721.ts @@ -0,0 +1,55 @@ +import { API_URL, MINIMUM_GAS_LIMIT, VERSION } from '@thxnetwork/api/config/secrets'; +import ContractService from '@thxnetwork/api/services/ContractService'; +import { ChainId } from '@thxnetwork/common/enums'; +import { getProvider } from '../network'; + +export async function deployERC721(nftName: string, nftSymbol: string) { + const { web3, defaultAccount } = getProvider(ChainId.Hardhat); + const contractName = 'NonFungibleToken'; + const contract = ContractService.getContractFromName(ChainId.Hardhat, contractName); + const bytecode = ContractService.getByteCodeForContractName(contractName); + const baseURL = `${API_URL}/${VERSION}/erc721/metadata/`; + const fn = contract.deploy({ + data: bytecode, + arguments: [nftName, nftSymbol, baseURL, defaultAccount], + }); + const data = fn.encodeABI(); + const estimate = await fn.estimateGas({ from: defaultAccount }); + const gas = estimate < Number(MINIMUM_GAS_LIMIT) ? MINIMUM_GAS_LIMIT : estimate; + const receipt = await web3.eth.sendTransaction({ + from: defaultAccount, + to: null, + data, + gas, + }); + + contract.options.address = receipt.contractAddress; + + return contract; +} + +export const mockGetNftsForOwner = (contractAddress: string, nftName: string, nftSymbol: string) => { + return { + ownedNfts: [ + { + contract: { + address: contractAddress, + name: nftName, + symbol: nftSymbol, + }, + tokenId: '1', + tokenUri: { + raw: 'https://ipfs.io/ipfs/QmRvCinGkzqDdmSZ3PzQRyHbQVqaFLTDyfyMMD54Bwcjsi', + }, + rawMetadata: { + name: '#1', + description: 'image description piece #1', + image: 'https://gateway.pinata.cloud/ipfs/QmemtAVJMkfUj3bAXee1H7vccbX6nC6Vbkbu6gBjdn1Kdh/1.png', + external_url: 'https://externalurl.com', + }, + }, + ], + pageKey: 1, + totalCount: 1, + }; +}; diff --git a/apps/api/src/app/util/jest/images.ts b/apps/api/src/app/util/jest/images.ts new file mode 100644 index 000000000..886b3090c --- /dev/null +++ b/apps/api/src/app/util/jest/images.ts @@ -0,0 +1,6 @@ +import path from 'path'; +import fs from 'fs'; + +export function createImage() { + return fs.readFileSync(path.resolve(__dirname, 'test.jpg')); +} diff --git a/apps/api/src/app/util/jest/jwt.ts b/apps/api/src/app/util/jest/jwt.ts new file mode 100644 index 000000000..05c88cfe5 --- /dev/null +++ b/apps/api/src/app/util/jest/jwt.ts @@ -0,0 +1,77 @@ +import jwt from 'jsonwebtoken'; +import { AUTH_URL } from '../../config/secrets'; +import { dashboardScopes, sub, sub2, walletScopes } from './constants'; + +const privateKey = `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAwaZ3afW0/zYy3HfJwAAr83PDdZvADuSJ6jTZk1+jprdHdG6P +zH9XaB6xhzvwTIJFcWuREkNSC06MDLCuvmZ8fj93FcNaZ2ZJ0LFvY4SODMDqFekE +5vD2Y15aSI2Y69qwKlVLphvEEXJ/FRqIHQX9wwCtwVsnqcLt/f5aNWRHyk2jwhz7 +IBm+dLu9/CV8AsvE5ddgOYYbNk+SMCjznESZcMg1KRzbdawnOklzloc+Q0iCxQK7 +022ukVxFbmT7U1hTVOTOzrruqBxptPDiutkKfOXebzYyZodlFFL5MWcatCWS3XL5 +1KBIeKWny5mExZPzIf1ofGuJe0zxllw8olgMqQIDAQABAoIBAB6x2DO/cpURbjZr +9lqsrErGirDVoze5GfM5tVMa0cHXQ0g9TiXH+X7TfqhE4+38qC02M6SFbzfDl4db +ahdb/1ezj5ivgmDpYcHmnhVUKX/0BCa87L3+a8+MYRsm9ppL66iKJJeLxyRM1b/u +mKyhCnwiW2hOnpbWAwtDieD0qDx0kzkYLevG3MivaAe1rDD6gS2LhPy6tGtmemRv +uT03a1X9DEO0U45oDvhi0V6dm8jz/eBBybHt0WWNNi2c5PVjhIL3HfT2UMiNa0xf +O3g1eGc8bdVPW4z0Kz1g+H/uvguTEqKVq4yT8Y2LuF0Vm2MxptqAmxxJTyL5q0ZI +V7PDg0ECgYEAziWa0BWbX+cMfIJXNi89aMpgBqBLdvQt9rE/TGjVeViPBcG8AoRr +Osxzct6tMd+DQO3uzx5DES2FiM7gbOtpGKnLWjJImmCq2gPCtWXo74I8il5egJNh +vq03eP7wdcm+bFta4+ScGv2wAbx28y3+5fQZPqzUISz+2dM8Hxx9GO0CgYEA8Hs0 +ap+FKD/nbRFfYMVLeP0WfJYuSpn9MF8FT73RSvCva4Ql0a3WbaZ5AMgLFGjm5nRp +idf5vPMaXbleDG1xZnkhhBXidwFmn4TCVvG/fiDjXOULuJ5qjKLv+5dTG72GDHGG +onNUwn1LQ3bJpGZ3VHIFJXOcQHl2Dxa6Cn7G9y0CgYBn0gyL67XasNRbCJG/mj8F +PZbq/2PCPuu/KDlG1C1e9bjiH1X+to4CiOFD4t27FmRWGP6ClS0Vw6VS502jzVOa +tjjR7i0egrzJG8e979tGdILk9O4HNzKtAzPC3jJgQACFNeUqjQIJneY8mZwWkP2k +9jCYnhYftzeKoJXQ3VoraQKBgQDiprxYYdDWhqRQH7eNNWZUufSfp8wpc8k19djD +t1uzDfXHl90tKnKXFfelzOTkb5pwSffOe0hd1aJcA4GopN3kfvYfz6CKGT/nyPCB +kYeyEL05qIbLkkNKGaelsJIb6xyUTctfAOQ6Cm0NQL/7urdtV6mSCsyR1+h1gC4I +BkTwYQKBgC7s9J9rRT23bx0NKyyHKu7/akAdC8m0YCohU5L7NNw0UZql0p8EQguh +bKhNO4+rlwh2VzIi5tVMQTYoUbaab8n17fdNxtfTsG0h4vj8q+7ab59GYf1TKn0R +JEn/NS0gRKfNh6bwZaSTfhFxALmKApVNTPm2UT9G5hADTcw4xTQf +-----END RSA PRIVATE KEY-----`; + +export const jwksResponse = { + keys: [ + { + alg: 'RS256', + kty: 'RSA', + use: 'sig', + n: 'waZ3afW0_zYy3HfJwAAr83PDdZvADuSJ6jTZk1-jprdHdG6PzH9XaB6xhzvwTIJFcWuREkNSC06MDLCuvmZ8fj93FcNaZ2ZJ0LFvY4SODMDqFekE5vD2Y15aSI2Y69qwKlVLphvEEXJ_FRqIHQX9wwCtwVsnqcLt_f5aNWRHyk2jwhz7IBm-dLu9_CV8AsvE5ddgOYYbNk-SMCjznESZcMg1KRzbdawnOklzloc-Q0iCxQK7022ukVxFbmT7U1hTVOTOzrruqBxptPDiutkKfOXebzYyZodlFFL5MWcatCWS3XL51KBIeKWny5mExZPzIf1ofGuJe0zxllw8olgMqQ', //eslint-disable-line max-len + e: 'AQAB', + kid: '0', + }, + ], +}; + +export const getToken = (scope: string, outerSub?: string) => { + const payload: any = { + scope, + }; + + if (scope === dashboardScopes) { + payload.sub = sub; + } else if (scope === walletScopes) { + payload.sub = sub2; + } + + if (outerSub) { + payload.sub = outerSub; + } + + const options = { + header: { kid: '0' }, + algorithm: 'RS256', + expiresIn: '1d', + issuer: AUTH_URL, + }; + + let token; + try { + token = jwt.sign(payload, privateKey, options); + } catch (err) { + console.log(err); + throw err; + } + + return `Bearer ${token}`; +}; diff --git a/apps/api/src/app/util/jest/mock.ts b/apps/api/src/app/util/jest/mock.ts new file mode 100644 index 000000000..8f8f7dafa --- /dev/null +++ b/apps/api/src/app/util/jest/mock.ts @@ -0,0 +1,78 @@ +import nock from 'nock'; +import { + userEmail2, + clientId, + clientSecret, + registrationAccessToken, + requestUris, + userWalletAddress2, + account2, + sub2, + account, + sub, + userEmail, + userWalletAddress, + sub3, + account3, + sub4, + account4, +} from './constants'; +import { getToken, jwksResponse } from './jwt'; +import { AUTH_URL } from '@thxnetwork/api/config/secrets'; + +export function mockAuthPath(method: string, path: string, status: number, callback: any = {}) { + const n = nock(AUTH_URL).persist() as any; + return n[method](path).reply(status, callback); +} + +export function mockUrl(method: string, baseUrl: string, path: string, status: number, callback: any = {}) { + const n = nock(baseUrl).persist() as any; + return n[method](path).reply(status, callback); +} + +export function mockStart() { + mockClear(); + mockAuthPath('get', '/jwks', 200, jwksResponse); + mockAuthPath('post', '/token', 200, async () => { + return { + access_token: getToken('openid accounts:read accounts:write'), + }; + }); + mockAuthPath('post', '/reg', 201, { + client_id: clientId, + registration_access_token: registrationAccessToken, + }); + mockAuthPath('delete', `/reg/${clientId}?access_token=${registrationAccessToken}`, 204, {}); + mockAuthPath('get', `/reg/${clientId}?access_token=${registrationAccessToken}`, 200, { + client_id: clientId, + client_secret: clientSecret, + request_uris: requestUris, + }); + + // mockAuthPath('get', `https://local.auth.thx.network/account?subs=${sub}`, 200, account); + + // Account 1 (Dashboard) + mockAuthPath('get', `/accounts/${sub}`, 200, account); + mockAuthPath('patch', `/accounts/${sub}`, 204, {}); + mockAuthPath('get', `/accounts/email/${userEmail}`, 200, account); + mockAuthPath('get', `/accounts/address/${userWalletAddress}`, 200, account); + + // Account 2 (Web Wallet) + mockAuthPath('get', `/accounts/${sub2}`, 200, account2); + mockAuthPath('patch', `/accounts/${sub2}`, 204, {}); + mockAuthPath('post', '/accounts', 200, [account2]); + mockAuthPath('get', `/accounts/address/${userWalletAddress2}`, 200, account2); + mockAuthPath('get', `/accounts/email/${userEmail2}`, 404, {}); + + // Account 3 + mockAuthPath('get', `/accounts/${sub3}`, 200, account3); + mockAuthPath('patch', `/accounts/${sub3}`, 204, account3); + + // Account 4 + mockAuthPath('get', `/accounts/${sub4}`, 200, account4); + mockAuthPath('patch', `/accounts/${sub4}`, 204, account4); +} + +export function mockClear() { + return nock.cleanAll(); +} diff --git a/apps/api/src/app/util/jest/network.ts b/apps/api/src/app/util/jest/network.ts new file mode 100644 index 000000000..62b18d430 --- /dev/null +++ b/apps/api/src/app/util/jest/network.ts @@ -0,0 +1,50 @@ +import { VOTER_PK, DEPOSITOR_PK } from './constants'; +import { getProvider } from '@thxnetwork/api/util/network'; +import { ChainId } from '@thxnetwork/common/enums'; +import { ethers } from 'ethers'; +import { contractNetworks } from '@thxnetwork/api/contracts'; +import { HARDHAT_RPC } from '@thxnetwork/api/config/secrets'; +import Safe, { EthersAdapter } from '@safe-global/protocol-kit'; + +const { web3 } = getProvider(ChainId.Hardhat); + +const voter = web3.eth.accounts.privateKeyToAccount(VOTER_PK) as any; +const depositor = web3.eth.accounts.privateKeyToAccount(DEPOSITOR_PK) as any; + +function createWallet(privateKey: string): any { + return web3.eth.accounts.privateKeyToAccount(privateKey); +} + +export const timeTravel = async (seconds: number) => { + web3.extend({ + methods: [ + { + name: 'increaseTime', + call: 'evm_increaseTime', + params: 1, + }, + { + name: 'mine', + call: 'evm_mine', + }, + ], + }); + await (web3 as any).increaseTime(seconds); +}; + +export const signMessage = (privateKey: string, message: string) => { + const wallet = createWallet(privateKey); + return wallet.sign(message).signature; +}; + +export async function signTxHash(safeAddress: string, safeTxHash: string, privateKey: string) { + const provider = new ethers.providers.JsonRpcProvider(HARDHAT_RPC); + const signer = new ethers.Wallet(privateKey, provider); + const ethAdapter = new EthersAdapter({ ethers, signerOrProvider: signer }) as any; + const safe = await Safe.create({ ethAdapter, safeAddress, contractNetworks }); + const signature = await safe.signTransactionHash(safeTxHash); + + return { safeTxHash, signature: signature.data }; +} + +export { voter, depositor, createWallet }; diff --git a/apps/api/src/app/util/jest/test.jpg b/apps/api/src/app/util/jest/test.jpg new file mode 100644 index 0000000000000000000000000000000000000000..96a94a982b5e8b9d6186650fec5a202069ef3c23 GIT binary patch literal 57854 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 q-NBFX5c<;{iH|LD=s)`SpNgx3 literal 0 HcmV?d00001 diff --git a/apps/api/src/app/util/jwt.ts b/apps/api/src/app/util/jwt.ts new file mode 100644 index 000000000..63ba995e5 --- /dev/null +++ b/apps/api/src/app/util/jwt.ts @@ -0,0 +1,6 @@ +import jwt_decode from 'jwt-decode'; + +export const parseToken = (header: string): { sub: string } => { + if (!header || !header.startsWith('Bearer ')) return; + return jwt_decode(header.split(' ')[1]); +}; diff --git a/apps/api/src/app/util/logger.ts b/apps/api/src/app/util/logger.ts new file mode 100644 index 000000000..e43d81206 --- /dev/null +++ b/apps/api/src/app/util/logger.ts @@ -0,0 +1,14 @@ +import winston from 'winston'; +import { NODE_ENV } from '@thxnetwork/api/config/secrets'; + +const formatWinston = winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.errors({ stack: true }), + winston.format.json(), +); + +export const logger = winston.createLogger({ + level: NODE_ENV === 'test' ? 'warn' : 'debug', + format: formatWinston, + transports: [new winston.transports.Console()], +}); diff --git a/apps/api/src/app/util/multer.ts b/apps/api/src/app/util/multer.ts new file mode 100644 index 000000000..5eefec84e --- /dev/null +++ b/apps/api/src/app/util/multer.ts @@ -0,0 +1,3 @@ +import multer from 'multer'; + +export const upload = multer({}); diff --git a/apps/api/src/app/util/network.ts b/apps/api/src/app/util/network.ts new file mode 100644 index 000000000..2ba95cec4 --- /dev/null +++ b/apps/api/src/app/util/network.ts @@ -0,0 +1,101 @@ +import { + HARDHAT_RPC, + POLYGON_RELAYER, + POLYGON_RELAYER_API_KEY, + POLYGON_RELAYER_API_SECRET, + POLYGON_RPC, + PRIVATE_KEY, + RELAYER_SPEED, + SAFE_TXS_SERVICE, +} from '@thxnetwork/api/config/secrets'; +import Web3 from 'web3'; +import { Contract } from 'web3-eth-contract'; +import { arrayify, computeAddress, hashMessage, recoverPublicKey } from 'ethers/lib/utils'; +import { Signer, Wallet, ethers } from 'ethers'; +import { EthersAdapter } from '@safe-global/protocol-kit'; +import { DefenderRelaySigner } from '@openzeppelin/defender-relay-client/lib/ethers'; +import { Relayer } from '@openzeppelin/defender-relay-client'; +import { DefenderRelayProvider } from '@openzeppelin/defender-relay-client/lib/web3'; +import { getChainId } from '@thxnetwork/api/services/ContractService'; +import { ChainId } from '@thxnetwork/common/enums'; + +export const MaxUint256 = '115792089237316195423570985008687907853269984665640564039457584007913129639935'; + +const web3 = new Web3(); +const networks: { + [chainId: number]: { + web3: Web3; + txServiceUrl: string; + signer: Signer; + ethAdapter: any; + readProvider: Web3; + defaultAccount: string; + relayer?: Relayer; + }; +} = {}; + +if (HARDHAT_RPC) { + networks[ChainId.Hardhat] = (() => { + const web3 = new Web3(HARDHAT_RPC); + const hardhatProvider = new ethers.providers.JsonRpcProvider(HARDHAT_RPC); + const signer = new Wallet(PRIVATE_KEY, hardhatProvider) as unknown as Signer; + const methods = [ + { name: 'setAutomine', call: 'evm_setAutomine', params: 1 }, + { name: 'setIntervalMining', call: 'evm_setIntervalMining', params: 1 }, + ]; + web3.extend({ property: 'hardhat', methods }); + return { + web3, + txServiceUrl: SAFE_TXS_SERVICE, + ethAdapter: new EthersAdapter({ ethers, signerOrProvider: signer }), + signer, + defaultAccount: web3.eth.accounts.privateKeyToAccount(PRIVATE_KEY).address, + readProvider: web3, + }; + })(); +} + +if (POLYGON_RELAYER) { + networks[ChainId.Polygon] = (() => { + const provider = new DefenderRelayProvider( + { apiKey: POLYGON_RELAYER_API_KEY, apiSecret: POLYGON_RELAYER_API_SECRET }, + { speed: RELAYER_SPEED }, + ); + const relayer = new Relayer({ apiKey: POLYGON_RELAYER_API_KEY, apiSecret: POLYGON_RELAYER_API_SECRET }); + const readProvider = new Web3(POLYGON_RPC); + const signer = new DefenderRelaySigner( + { apiKey: POLYGON_RELAYER_API_KEY, apiSecret: POLYGON_RELAYER_API_SECRET }, + new ethers.providers.JsonRpcProvider(POLYGON_RPC), + { speed: RELAYER_SPEED }, + ); + + return { + web3: new Web3(provider), + txServiceUrl: SAFE_TXS_SERVICE, + ethAdapter: new EthersAdapter({ ethers, signerOrProvider: signer }), + signer, + relayer, + defaultAccount: POLYGON_RELAYER, + readProvider, + }; + })(); +} + +export function getProvider(chainId?: ChainId) { + if (!chainId) chainId = getChainId(); + if (!networks[chainId]) throw new Error(`Network with chainId ${chainId} is not available`); + return networks[chainId]; +} + +export const recoverSigner = (message: string, sig: string) => { + return computeAddress(recoverPublicKey(arrayify(hashMessage(message)), sig)); +}; + +export function getSelectors(contract: Contract) { + const signatures: string[] = []; + for (const sig of Object.keys(contract.methods)) { + if (sig.indexOf('(') === -1) continue; // Only add selectors for full function signatures. + signatures.push(web3.eth.abi.encodeFunctionSignature(sig)); + } + return signatures; +} diff --git a/apps/api/src/app/util/newrelic.ts b/apps/api/src/app/util/newrelic.ts new file mode 100644 index 000000000..4f3beb035 --- /dev/null +++ b/apps/api/src/app/util/newrelic.ts @@ -0,0 +1,12 @@ +import newrelic from 'newrelic'; + +export const wrapBackgroundTransaction = (name: string, group: string, promise: Promise): Promise => { + return newrelic.startBackgroundTransaction(name, group, async () => { + try { + return await promise; + } catch (error) { + newrelic.noticeError(error); + throw error; + } + }); +}; diff --git a/apps/api/src/app/util/pagination.ts b/apps/api/src/app/util/pagination.ts new file mode 100644 index 000000000..62c9c9ed4 --- /dev/null +++ b/apps/api/src/app/util/pagination.ts @@ -0,0 +1,45 @@ +export interface PaginationResult { + results: any[]; + next?: { page: number }; + previous?: { page: number }; + limit: number; + total: number; +} + +export const paginatedResults = async ( + model: any, + page: number, + limit: number, + query: any, + sorts: any = [['createdAt', -1]], +): Promise => { + const startIndex = (page - 1) * limit; + const endIndex = page * limit; + const total = await model.find(query).countDocuments().exec(); + let next, previous; + + if (endIndex < total) { + next = { + next: { + page: page + 1, + }, + }; + } + if (startIndex > 0) { + previous = { + previous: { + page: page - 1, + }, + }; + } + + const results = await model.find(query).sort(sorts).limit(limit).skip(startIndex).exec(); + + return { + results, + limit, + total, + ...next, + ...previous, + }; +}; diff --git a/apps/api/src/app/util/path.ts b/apps/api/src/app/util/path.ts new file mode 100644 index 000000000..98377c9a6 --- /dev/null +++ b/apps/api/src/app/util/path.ts @@ -0,0 +1,4 @@ +import path from 'path'; +import { CWD } from '../config/secrets'; + +export const assetsPath = path.resolve(CWD, 'assets'); diff --git a/apps/api/src/app/util/polling.ts b/apps/api/src/app/util/polling.ts new file mode 100644 index 000000000..87f5adc8e --- /dev/null +++ b/apps/api/src/app/util/polling.ts @@ -0,0 +1,14 @@ +export async function poll(fn: () => Promise, fnCondition: (result: T) => boolean, ms: number) { + let result = await fn(); + while (fnCondition(result)) { + await wait(ms); + result = await fn(); + } + return result; +} + +function wait(ms = 1000) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/apps/api/src/app/util/random.ts b/apps/api/src/app/util/random.ts new file mode 100644 index 000000000..2179cba15 --- /dev/null +++ b/apps/api/src/app/util/random.ts @@ -0,0 +1,3 @@ +export function generateRandomString(length: number) { + return (+new Date() * Math.random()).toString(36).substring(0, length); +} diff --git a/apps/api/src/app/util/ratelimiter.ts b/apps/api/src/app/util/ratelimiter.ts new file mode 100644 index 000000000..800919f55 --- /dev/null +++ b/apps/api/src/app/util/ratelimiter.ts @@ -0,0 +1,6 @@ +import rateLimit from 'express-rate-limit'; +import { NODE_ENV } from '../config/secrets'; + +const limitInSeconds = (seconds: number) => NODE_ENV !== 'test' && rateLimit({ windowMs: seconds * 1000, max: 1 }); + +export { limitInSeconds }; diff --git a/apps/api/src/app/util/s3.ts b/apps/api/src/app/util/s3.ts new file mode 100644 index 000000000..57a6c78b5 --- /dev/null +++ b/apps/api/src/app/util/s3.ts @@ -0,0 +1,26 @@ +import { S3, S3Client } from '@aws-sdk/client-s3'; +import { + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, + AWS_S3_PRIVATE_BUCKET_REGION, + AWS_S3_PUBLIC_BUCKET_REGION, +} from '@thxnetwork/api/config/secrets'; + +const credentials = { + accessKeyId: AWS_ACCESS_KEY_ID, + secretAccessKey: AWS_SECRET_ACCESS_KEY, +}; + +const s3Client = new S3Client({ + region: AWS_S3_PUBLIC_BUCKET_REGION, + credentials, +}); + +const s3PrivateClient = new S3Client({ + region: AWS_S3_PRIVATE_BUCKET_REGION, + credentials, +}); + +const s3Public = new S3({ region: AWS_S3_PUBLIC_BUCKET_REGION, credentials }); + +export { s3Public, s3Client, s3PrivateClient }; diff --git a/apps/api/src/app/util/scopes.ts b/apps/api/src/app/util/scopes.ts new file mode 100644 index 000000000..024bb03b7 --- /dev/null +++ b/apps/api/src/app/util/scopes.ts @@ -0,0 +1,63 @@ +export const openId = 'openid'; +export const adminScopes = [ + 'account:read', + 'account:write', + 'members:read', + 'members:write', + 'withdrawals:write', + 'rewards:read', + 'wallets:read', + 'wallets:write', + 'erc20_rewards:read', + 'erc721_rewards:read', + 'referral_rewards:read', +]; +export const dashboardScopes = [ + 'asset_pools:read', + 'asset_pools:write', + 'rewards:read', + 'rewards:write', + 'deposits:read', + 'deposits:write', + 'promotions:read', + 'promotions:write', + 'transactions:read', + 'claims:read', + 'swaprule:read', + 'swaprule:write', + 'erc20_rewards:read', + 'erc20_rewards:write', + 'erc721_rewards:read', + 'erc721_rewards:write', + 'referral_rewards:read', + 'referral_rewards:write', + 'pool_subscription:read', +]; +export const userScopes = [ + 'asset_pools:read', + 'asset_pools:write', + 'rewards:read', + 'withdrawals:read', + 'deposits:read', + 'deposits:write', + 'transactions:read', + 'transactions:write', + 'claims:read', + 'swaprule:read', + 'swap:read', + 'swap:write', + 'erc20_rewards:read', + 'erc721_rewards:read', + 'referral_rewards:read', + 'referal_reward_claims:read', + 'referal_reward_claims:write', +]; + +export const opneIdAdminScopes = `${openId} ${adminScopes.join(' ')}`; +export const openIdDashboardScopes = `${openId} ${dashboardScopes.join(' ')}`; +export const openIdUserScopes = `${openId} ${userScopes.join(' ')}`; + +export const adminDashboardScopes = Array.from(new Set([...adminScopes, ...dashboardScopes])); +export const userDashboardScopes = Array.from(new Set([...userScopes, ...dashboardScopes])); +export const userAdminScopes = Array.from(new Set([...adminScopes, ...userScopes])); +export const userAdminDashboardScopes = Array.from(new Set([...adminScopes, ...userScopes, ...dashboardScopes])); diff --git a/apps/api/src/app/util/signingsecret.ts b/apps/api/src/app/util/signingsecret.ts new file mode 100644 index 000000000..736149ad2 --- /dev/null +++ b/apps/api/src/app/util/signingsecret.ts @@ -0,0 +1,11 @@ +import crypto from 'crypto'; + +export function getsigningSecret(length: number) { + return crypto.randomBytes(length).toString('base64'); +} + +export function signPayload(payload: string, secret: string): string { + const hmac = crypto.createHmac('sha256', secret); + hmac.update(payload); + return hmac.digest('base64'); +} diff --git a/apps/api/src/app/util/token.ts b/apps/api/src/app/util/token.ts new file mode 100644 index 000000000..786573023 --- /dev/null +++ b/apps/api/src/app/util/token.ts @@ -0,0 +1,6 @@ +import crypto from 'crypto'; + +export function createRandomToken() { + const buf = crypto.randomBytes(16); + return buf.toString('hex'); +} diff --git a/apps/api/src/app/util/twitter.ts b/apps/api/src/app/util/twitter.ts new file mode 100644 index 000000000..ea07f182a --- /dev/null +++ b/apps/api/src/app/util/twitter.ts @@ -0,0 +1,8 @@ +import { TWITTER_API_TOKEN } from '@thxnetwork/api/config/secrets'; +import axios, { AxiosRequestConfig } from 'axios'; + +export function twitterClient(config: AxiosRequestConfig) { + axios.defaults.headers['Authorization'] = `Bearer ${TWITTER_API_TOKEN}`; + axios.defaults.baseURL = 'https://api.twitter.com/2'; + return axios(config); +} diff --git a/apps/api/src/app/util/url.ts b/apps/api/src/app/util/url.ts new file mode 100644 index 000000000..4b292f77d --- /dev/null +++ b/apps/api/src/app/util/url.ts @@ -0,0 +1,2 @@ +const URL_REGEX = /^(https?):\/\/[^\s/$.?#].[^\s]*$/i; +export const isValidUrl = (url: string) => url.match(URL_REGEX); diff --git a/apps/api/src/app/util/uuid.ts b/apps/api/src/app/util/uuid.ts new file mode 100644 index 000000000..b50ed5c80 --- /dev/null +++ b/apps/api/src/app/util/uuid.ts @@ -0,0 +1,23 @@ +import crypto from 'crypto'; +import { v1 } from 'uuid'; + +export function uuidV1(salt?: string) { + if (!salt) return v1(); + + // Use a cryptographic hash function (SHA-256 in this example) + const hash = crypto.createHash('sha256'); + + // Update the hash with the salt + hash.update(salt); + + // Get the hashed data in hexadecimal format + const hashedData = hash.digest('hex'); + + // Create a UUID from the first 16 bytes of the hashed data + const uuid = `${hashedData.slice(0, 8)}-${hashedData.slice(8, 12)}-${hashedData.slice(12, 16)}-${hashedData.slice( + 16, + 20, + )}-${hashedData.slice(20, 32)}`; + + return uuid; +} diff --git a/apps/api/src/app/util/validation.ts b/apps/api/src/app/util/validation.ts new file mode 100644 index 000000000..4b6f306c5 --- /dev/null +++ b/apps/api/src/app/util/validation.ts @@ -0,0 +1,65 @@ +import { body, check, validationResult } from 'express-validator'; +import { Response, Request, NextFunction } from 'express'; +import { isValidUrl } from './url'; + +export const validate = (validations: any) => { + return async (req: Request, res: Response, next: NextFunction) => { + await Promise.all(validations.map((validation: any) => validation.run(req))); + const errors = validationResult(req); + if (errors.isEmpty()) return next(); + res.status(400).json({ errors: errors.array() }); + }; +}; + +export const defaults = { + quest: [ + body('index').optional().isInt(), + body('title').optional().isString().trim().escape(), + body('description').optional().isString().trim().escape(), + body('expiryDate').optional().isISO8601(), + body('isPublished') + .optional() + .isBoolean() + .customSanitizer((value) => JSON.parse(value)), + check('file') + .optional() + .custom((value, { req }) => { + return ['jpg', 'jpeg', 'gif', 'png'].includes(req.file.mimetype); + }), + body('infoLinks') + .optional() + .customSanitizer((infoLinks) => { + return JSON.parse(infoLinks).filter((link: TInfoLink) => link.label.length && isValidUrl(link.url)); + }), + body('locks') + .optional() + .custom((value) => { + const locks = value && JSON.parse(value); + return Array.isArray(locks); + }) + .customSanitizer((locks) => locks && JSON.parse(locks)), + ], + reward: [ + body('title').optional().isString().trim().escape(), + body('description').optional().isString().trim().escape(), + body('expiryDate').optional().isISO8601(), + body('limit').optional().isNumeric(), + body('pointPrice').optional().isNumeric(), + body('isPublished') + .optional() + .isBoolean() + .customSanitizer((value) => JSON.parse(value)), + check('file') + .optional() + .custom((value) => { + return ['jpg', 'jpeg', 'gif', 'png'].includes(value.mimetype); + }), + body('locks') + .optional() + .custom((value) => { + const locks = value && JSON.parse(value); + return Array.isArray(locks); + }) + .customSanitizer((locks) => locks && JSON.parse(locks)), + ], +}; diff --git a/apps/api/src/app/util/zip.ts b/apps/api/src/app/util/zip.ts new file mode 100644 index 000000000..28c845e99 --- /dev/null +++ b/apps/api/src/app/util/zip.ts @@ -0,0 +1,9 @@ +import JSZip from 'jszip'; + +export const createArchiver = () => { + const jsZip = new JSZip(); + return { + jsZip, + archive: jsZip.folder('qrcodes'), + }; +}; diff --git a/apps/api/src/assets/.gitkeep b/apps/api/src/assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/src/assets/bg.png b/apps/api/src/assets/bg.png new file mode 100644 index 0000000000000000000000000000000000000000..e58c94831a2f82615163f657a792fcfbd958a7c1 GIT binary patch literal 117866 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

3*Hkc?!yI)KY#h6=Y){*$U%c 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 DxQa|ldk595Shv9FYbKqp)|!MqJGMlMxxT0f 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�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 Cv||zg literal 0 HcmV?d00001 diff --git a/apps/api/src/assets/fa-solid-900.ttf b/apps/api/src/assets/fa-solid-900.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e6330e6aa71d7eb0a8c75f4c59b16326811cfdec GIT binary patch literal 383828 zcmeFadth8e`9J*3x$N2N*-dtnP0}>o+|z5)+FjUf0!&VSL5w8KE-YD8#rJa>R!1mUvP*RJ2NEnz%{@E#O@YVGw`T%NUWAUS zePwH2>iXnm!Kuf|ryv{AA8Ky@f#W_uaBSea%ERI{gx#cY4*cVMiGc)>5=VMUdDw}U ziTp8UaEviSTud<;$&7`hvSgS@Q#5rG>4a{ul|nCh&iorId(qoD?KMp|oGRcDke!3D8)XJn6i)O9KNU z&OB_F{dPFRmlR8;zcVx*Ww<%s$(xd8#D|xWiKBscBt7RD4leh7Sr;yY^Rr71;!fPq zaN0lAwdrD9UK)~@U6$QGag>wJW77|oV>#tm6m!~ju#H$g&X=Z%5Bi6+RWQvP44i^XPS}usgdKj| z=2DepUs;)WyR5h)6WpQsK$|TDBm9D$&7Yq7mK~~7Yd0F=z9yUMB zjEd!?!R29S(+zRCkHG_UvhOATG#&Cde4#r7@26!0tt}g;GYI`oHZA9Oc%F%H$PT8C{ouBhsWH2Z}Q9!0497rQApL8vzN1c%`-F~9& zG=2#uOip>wd)dzMLAujq%tJU2<*_W>)_D?6Sb@vs$vo_*VQ_Q%q+!b?)7{c9`-sE# z_(=B=+&oHv(dlz0_{;k6SjG6V<+ODJ;bHxfslVMnZt&uIuzdEs2)}59-Z9wkK(QQo zhs0&-9)qrMdYr;iR@*Q20(}fVtS>eO!*+XQc%=DaapXEe9tMvo>CecBuq-2P%ZBuj z0>1>OC7g7p?d<$K7dm4lK5!~c!+bsp$jfb)o|o9qZ5v~rk_XCSKfr0WoN>4o0GDZa z{*>-uKiG7fMgZDQx)%J5xD!uKevSjsZto1?DaSq6NyLyA2hwiWV1C6Kl7@H@vmwii zJ7vMm)MZGVl!MbOfE=FePdNTK{HKBW+IZV4dOPBbeU;>BF6O4U(4a| zw3kdroJ}v=%FX4=VJ zAG#*T7^nGV9_H5xFn>F*97h;u@7S_rbc5&rkmAg{>GI%)I4@kOXHv#^h8Ka})-8)^ zSclV)b&+YPPk_skaQw(;%ftC>IC9xxF6WF$43_Qi_ zIp7Sgq;uk}p|VPOv0iZh59*}CG-<@s^n>-t%xf0w6zmb!XJTGV3;pIi=`h-Z(`DNw zScbv0v<}++#r%=ZdD*q3`!36d@Sxo#(>cwC-DaozF?NtXG)>YI=&ZrKX&&jc;qnac zALca_>AbQGa`-x~;5Gn#ral((#)ryd%a`WCuFZ#a-lmmth_`8+_O*~kPTWwJ{i}K1 z8@dm(%a`L4>9TH?T@K5aE;~~m^GL&T_F(BSyK#p{I?l4`Gh^bku+#y>Ntg4$FV`t+ z$oxZCKb^VgjBdYOrj#8#q#GZM%j8KQPV%>9QeuwFaq%JaL-?ih%64En2FWp!N~dLH zC!3$$N48(m#^Hyq$~G7>r%HWw=GP2=LjO2?lst?#^-0D_deHJ1 zfFENG`>%n2@mS5s!eiUXpNX@;i$TcKWxH~W^0;DfJ>y&+(hu0QvaV@foR8&aH{k%Z zG7pburn5MHux!bfX=n)I@~p(^w=^xrGU(0(*=|mn^(N1Da?ocWuayl^2;Kg!8F4Kaj=fZIy;w_P9R#d!~)esRgy zIqze?lP2RqlNgKxj{(tTrz>&6wAV|SxIc!|+3oD`P%JxL(#Srr$BmuGX*1+U`^j?= z*$&9VvM^1i|2PaF-EQYDcGGzfZ=uc>je<<}zK~RP=?D@m8WwZ0jHs^J>CtW`L2qUk=kp8qX{G?4Q+lJ$qjvP7BK7)Df zG^b5@KI1tlEdz88e$J1)jx4-~<9OHvLu8C2-C|yjT+)y75*(M#dzzo@w#xJi@(k-I zq-V-Sn)Ih#;<6=d+db~^maenzvY(uIcGG_Av~oEfafAHR_JSin(lYgkJN&qw(3wo# zINnObULP`Pl#YkXeyp>Z_Rr)&eB4R5cpvM`d7RGiL)u!lQG#ii#-U>x8V)Hl=Zj0& zx(qtm-t2Zl=cO!?k8DqSy|ekiwuynit!I)q#%+Sz9O)L*p#JPKe@m`~C(|;>@sU|0 zL1<4zfH!IRgJbbs2mCWj@B6CdY3b>cF4v47|MPMFid>%BBMf2OUhA$})b zL7d&TvM%6n%an1EKFE7WT3q_EN5$Ew^r!Vj_66F^p+$c9Ezl@V++Z5haC#bX_!+Ve z6qD`DVT^Te5MaE{BgOPq>^_%nL$#0i;C<{eTFhppl5$#iKx z|H+m00eyyd+D{yZezu&l5TE8H^Kd-kB*>v**K+)EcBQ`9_B0{PkemPv{=$Ujq`IO3igQYH#ar%74JmI2W* zS$7V{xo(3z70aPvH|~J!ILQNXX_xa!U9|>jWqF_hUugqEX1+^vcsX+z=p|i3x-q96 zW&5OgGG7OFI>M3 zvkc}Fsyyf1lw@-xW?k`E>yPClCa zO7ijK_mV$MzMlML@{h@`6(Pv~ywp4>gX`>gH--4}JQ z=)SUhL-#%1o4Y^X{c!gq-Cykfa`*STcXdC}{Y>{O-LH2a>i%8#AG`n7eXRRLcdAG9 z7(Kq8qMq?RjXjfk&gogub7{}oo|}7a?fFd4XL}y%dAR3)dcM-Ly=O<0p>4bHY54bz=I7nJ2cL_}+XXY)-hA?wlbcR%KDp)O=T3g%+;H%Un?X(U7FnW*fU;-r;4JK2h! zX-l@FXYP?b^Vwua^7H7KZOP8$*OI%EiR6LgZ_qPuCjXvHCi}aoJJ22K&O^_{y34w2 zx|_PEbz9x@x?8(1>2B-(WcS_O_jW(%^vt&I@1SRX*uAGa(fyk2nZw;(-Ty?-^!HGY z*5mDo_7wNj^)&ZbJ?Hf-LeH%0xkdI&hwPaz_jLArv*$-n&%Ds{a?gRD-}n5vC)rEA zg}t%fnqI576+P3|dlP!*uHO54??=yk$?2KrdS8$|^BQ{Q4^Gb{(KGM%4xndD*)z3$ z3;HfV&s^O1o4$9C=N$k1@yCvDKmM)bPaJ>p`18kKKK|PA1IK%g_aA@nMCe5Lgmq%} ziLad4g`Ros#2-%TCu>ixK+kMEdEd!TpM2otLnr?SJ+u4dvnO9ZdGO?KPriBb2zq9A z|6~2%?0>cYSN(7H|6^bvrpA<*h|#ElQK?byk9v31vCxg78$#d(Hp)sM+p{h_ts612_Dh-u{ibL5UPskmj;6N}Hd_VYJ@a^Egg2#eK zgGYjY4ju~rHu#(1uY+#{UkUCDz8w5%@F&4N!6$=12<{4gJNV7u*MpA)KOcN3cz5uw z;GMzSgSP~44&D@8A6y;0DtKjZW$^OgrNQOFCBa3(*5Jb6g5Y_w*)56N2M|Rl%a*h+s764U#!vzHh#3o-|LGZ<+rz|7QNl{DXPe{H^&L^9}P=^T+1X z=1%iF=C{pnnLEsHnh%=ynfIET%zMnc&AZIFd8c`YdAqsMyv@ATyv5vL-fZ4ve$rfT z-e|5f+s*6E>&&(0HRjdkYIBu&m3gIkh1q6aYA!b~HZL-lnHQRi%?r$P%(>y2EAh6pI+V#X1?sD0GG({p|l?f;+l|56JW)Bg_& z-UGG3u1Z-AlcgTO397aO*YO)*vn&Tr;fkw@$TNq?i#KrIy|@C^aNXEJWFEzp6>OO- zynV~+BFcuFy$w%Uz$c7wWD8MF2slKP3;L)9bQ0x(e?HO**5i6{9!w(eE=1hOwRooo z-lP0PG2|;QBr1WwWFt{2P=>ta2$#o!KB9`%0OYM|B^o`IXv`*}v3rTe!5!aDGy%LO zfVLWSsaZ@kF$NqZsy#qd2h_vgh`1)m(G0%LJBcQ3Cz`yRXv!9%smq9_)ev#BPDlCE zQPzyDL>6eR%|tW7bIviMv-ach9BAi(*EtxFi{=qs903r2$q}OEDC^RdL@U7SGVp0@A-WvlD^TvrLZU0D z0+8d%qeNG=0*?ZHM5{ozdK=Li@Vj~s(KWzY4cI_*E%II4Npv0ZUXQY_N8a}BMC(xg zy4^%KK$aW!65R-X>rw9dexgsJyie{Ux(R%5+60Z{tRy+rjtt)kJqd{yQMsohT~~es@8hyHMWUz&)FYHX&{k^4}W+wgQKV?gP*J z!0S_GM4QI}?ZAGb`$2O*=J7L*7pC@9ZS{S`1+KFwu6TZSN!c zI>O(uh`vbxWZSU}fcq`*`_>LTsDv!vM)*6;z<#2g9Yo)q1N0MpZzs|B+kk^ayXFCV ziGEN6oFe++MxrMk1r8DIZUsR9Bgpq8cs->7?L>R>0MI<$4D1FV^N-gPJp+06g4eSh zM9(4ZxlW?zrvgWb63Ca>4jdwS!4Dw*1<<{4gy_WxFb4qLPgWAW6av8SrF}#{1&^O0 z?qv-C{XWp`1KlfIh<+X?dbJfmnfp=Re#r7#g6Q?_M88-IfbIb3ewhcreFJ>o*h=&( zgnzXSI6`!A9IydEzF*e>JAi(o-^>FJ6a5x)9Et%b_Yi3Q7jpk^C(-XLqTeHY82Cdy zaDeEK%YjFU{scZ<5ul6c&+P#Cyjex`7Y%^_NHc&k{tDW^Edx;I-(vuH99<3UBl-vE z|FIc3M)XgV@zy+G7ZHMVtQ9y#^e@QtFYx$RlIU%e{WkLb8?yX+FHsU{-JtDW4ZK9u zQwVGz>P33*Zlb=mKtIv(4x$qwqLbr*I8lEY(K~Jc1-*Na=soZ{wS(w=(4`g=4demf zGjIr&LNo9v2`v0Vtj23z>|?P;2<0FNbt{l0p^XEMk6YDjon zN%$;aFA0Aeu#ZGwBMB1-0-+WF{IXD1b`=0#;Z8iYo&y{pk+Y3NZaaynn?zm&Kw7>Z zSV^J)aRo1t7*R%|5M>o2ZRAuEMUbOtFNxS%5?DV(G3rpVo8@0CxLHoWBh~{tKFcodDt%uP3pjmBi9Q0C5+hjg}#N zQG^86E^$c>iRBiFOA{nkpuEdaZX4(>-%a8Z8%SJH1%S`WHWF77fb^?6NUTDhRY?-7 zJ4vj8{~GYRri;YdBP6b^2i5{dNn8gW*Y6{-ZWoCgrjocZ2B4f9k#{}vtv^NLlPK>d z(A~TY*iT{ua0>y*cWWDo+cW?)8^L!Y;%-NsZwJjCt-v7?ccSb&HvFv9=W{5dV=-`q#Dk!DXd?-%MdI@ZNNffFt@}tk+yWdV@rC6i9?<~M zJkp0FrL_R^eG&0r1pWU+fMvio0J44w{I<1{zn!>?W~&D)17CucQ30??83~6|kAaPm$+m%SpUk20*5l!DnAHu#?0qkmKit z0OWeL4cJ3sKV;p%i^OXd0GVC~@7E8L_yzJF0PQasn@GF?x?e3OaS(Za-9q9wi2p74 z90L9q_#MjoT_1_xZzpkhH30t~wvhPaauR=9NutXQpqxL?0}hjT6aK$6lQ@F#Un9VN z5`UWtY$Wmb8WKnAf!!qju?*-Z@y`wbb$APDZyhCZYzK*dttRm{+V9_xCz(g0djp9c z#Py&ay=zJIfo~t^`i_w}9w&h{Mw}!7GWNHTc&DAjyWsyG=-%5(;?x`x?>7S|HwAyH zi^RZM>^bX!L!^iUxXn04in^Q>4O0Peoh%y(#U9^>y^E*kw+Mz674uH>60=58sq+AGhSv`Qf7g@kTQZ5FM zOV*OIJOV)0<^7~wx`&h%pj+`MfbeDDg>^+~n+ohE<#Nz}qKcF&5WWIsuk-`Rb0y-i zz9?7K06R%p1-jLXNm&ElYc`W|^*mCpSq+>bWi8~y-blF?@z>=6sO$B+NNGo&b%_+rSpeJ(;B^bqZk2Q9Dfg}>5HYi_#43AX-`$*|LM#|#{Ncoxuz}*hMU*Aj0H(E*gCiw5LfG$!n z=PTbPQoe(-F}EwmAJ{|+_9Ds;_LK6%Hd3BITRf2n?Dk)DF0-J$Tr2H7NK7(?fIZVo41P0CLe0|!WX3GPoJ%TL#n@-y&!8S?F`2SEP{_`GtIl%J!VSCQw{gQV=A zO3G_-QeGcN$}d_-IWP}6ManO?lJdr4Qhrqi93$l*(hh?EulJMkn{A~0b|)!^wvh6_ zp#NPvDTh(UA3*;{#Q$j-DP555&z+>axto-~JW9$D$Z=#ZDSrjsUs2xQkoWK4a}@Ra zM+oR5<*li}F;b2lB;{YAdD{;l{@+JPNhV0?ZUg#A>Dfg}Z!0N%1hkNH9QjUE0eeU} z*$kkZe#G}9&pXKTZXvLhl=o2fdxuCl1^@e?f4_^A0mwOUgjCu{swf0@lB(1L`$<*j z0jEgST7e{~dK;;(wWJy$0QBw};25c%U8H*3N%bM0FG;F@F|e1^Kn&OnfF?MV)X;8H zvyh&RwD1m6BMDM-%SeU&uSStSZyc%lA)t@cf0pL!Ce3OxH3f!q{NuAb4>U7981FnTQ3+2yT4J1gN zjqn`M&V~Ohq{CKM=RwAK2%m%abC;7k{{X2iDC<0=pO++cA<|kAw+J#^;0Lyox}*aD z-wO)?lgO-An3i z`$@&zsop-7)H_1JCQ|PNeH`V-_mO(na-fgYySI~iPn^_EHKg8K26O_5zYp?!s*BXk zsN3eFq~5=g)K9kq2~t1P4D2R#3wV4M`5w@KwZJh_KevU{2O-NtZKQr4@@|Fy;YUgR zLJR=EM>es+? zI|0b^b;N&TE2-Zc2Ozv7N$R&cNd0yM*iPzqb^!gP?rZ_T=esYF`aR_NK6vb^Ar*6} zig{Fhq72we>TdA*5%A=0QlA2kr;umQVN#!7OzMwAz}4N)(RXT^<~h%jB;M_0|`=p4!pXP)cuHm4K%O2fh_>a z{RP|u5mJ9i0NUXV(EMsGsRw~yBktE5N&U@KQhysF^^gW2&+itK`ui4A4{s**56ejX zBk2AJS-WDS{&_d4e_2WDk)x#k6}Obm%PE!8~`nRfp{iGgiCiP#Sd3!mj z|Aw1H{v>F-=aJgu26m9z3);RKQjdf71kz7Jj+2m~{{X4)Am6*-^Iiw3rxuf%+D0nu zRgG4YCbp8M>?2L>Bu!gSnyU=xBF#;}UeY`pN%L+Z&3BMAKk@|XNi##h9@0WJq-Cun zE&EZ@!azhLEoUlexyYNFAT7F>v^>z~BY#0FaD=oG;8z#{`bitPg|t!4q{aNedeVx4 z63A2vxAZV+Wu2sz?0)T(jJkl`VX=4_XHWqxwg5S8kq>V@1_=BWXBfcgE#DOl- zCT=FJb`EKE7O;)9`a`5OAgys6u!XcHl-&%uCdB~I;w(d(44zXqkTw-%P6hq6eWXoK zkT#=(G;29&Gf~FO9i+`dcy<+Ob3(v&(qLn1urIZ-tz^|Q8njQh0!h+v)}U%*k|<|Wd8i}*v}_rEm&!oOPvfZy-I?{Fcoo3uZ)kcPQR`{OCny5M$gB<;^_q`ese zAlF}z_b*+f9oYh)?7w!Bb`<6QGfCQ8i%C1Ck@j{30N-R8fb?#l2Yh>ukk*Ux`|5#x zq#cL+Cz^r7q@CPAT0ipjzeL(Qn*ij0cL!5#rK|ARqkf*R2*g^V8 zDyUd7tFs-%g;fXB6oa%l!60%`Y#^bui5IAj zCedsCHQH@O%4fT+_u{JtJ7}2^WqrRdP zHOsAStjzdawT%t+m0-rKTse`-iuxH!W9=v+8UkZG0-BWr`2&sUOhj?jq_zytqLhFB~+F6h{tkgS4>*w ze}Y@Eqb(c=86IvEG*m&jxoYXzfvl{G*|{PXi;f<>MlyAHdTyEDP+h*58*y=YbwkH6 zw#=W~O+kPE3BRQ5q++Uc+RH#+msHC3s6-oZ=i9Bv-L6(tmbwh_{fe=*S$U}wd0Dk% zD+&WyQCaEwGO@YDJMpDjuP}=)tesh(on1e(cIl||qgnG<{$*wLsjeyI4s^C>N}gfi)?H%t7J7cZSC^!>a@gt#+>0rxh0q zk4q?X(S|C81`=Wy>N0|+K=%k^Bbv*NSkW|HOc4ze1ayn*2TiPmywee`jMhrQp=Czp z1k?q!i2|Tw{i+vMPMJ&fx>H?luj(qxnYZBTTX)`i^@4bAe!5t4-Fa{IC%S7~%Y4&rPjMZ@qPC>rI~Gbpd4Zc(Q%&%I4@5hM`3&M$IwL z_XIzK3?5VJ0e?iCO8Gh;W8N7vJg`lS7H9&LzP@n^hDt+IG$%J&>5>tnxN;cu7(`8t za;B&jJieU)8VF5mnHIW~d;jKWNi0^J_jkkRGp>{RavgggNUWG8wRvP|=}3>!W_Tj* z8da@vN7{tfUF7x(e<5ct^gF?zY;(eRXZ`Wg1UiREuWy_oSm=@p_5V`%&XC(3!|1a9 zn@YsoF~5I8HYy^<{~y$c$Dv)H*)$*R&x61eP>f33hcQ`#5cH|Z_VM^cC|V?!^?H1&w`77|R2+^pEDC&NYAPjX>F@n_xrXt) zKu+A~GhSN~a&1v5NMULuiZ&GIRPr>-s|M!T%7$X2DK{s}o^g1F#wcDfq zWR-xfc#Ncoiz|vehGlrdX6kMe(=NP4BINdZ-BxF3n^bX6TU*X^ zVqwg`PJ?V%`_uB3(7ALzT~6(onf|jh;Ua-Ka&VC;b%xF~AqU6|!IJ={grKOaVg;aQ=RMd~DvhRQI-6khHx(Pj`hRMSI-{<#?n9d({GXc}oi9>% ziACn=P2#R~Kj*G}=R+D|_lLD@mo@vtTA?%+Yd=%{#gva|RICTEp+o{}Y5_G+D^WNn z5|uNfvy4_cGb!dN{mk(rl?d#N56fg#H9Pi^bi}-2!H&~XtHhLX(-%)?y`K)>k}CFf zd>{s7Q%|E@&6HRcuvRA&3v=yQtZ#B|3JT0Z)ofewDhInt26-YXhdng7A{eI`hRPIO z%he2*9>Va82f`uUW#~DYF34Zxzfum|&VYNVJ0L#?bx`K%n)wC~*EdWJ0noqc^k?n*gRMc{hdY&XbXDXkOn}eqwPjB zTxeAoJ9V{~wc)Kit-s6Iz(xrh2C?Cso9a_vSDGD6Zg^xV)W ztZBLnO*~47D`S5h-juf+<8usuR@4lJ0>YQ0-apu=VvbzBo{SiyP2Dra>o>FAwcdYZ ztDcBqMDyHw_5`1nE5ZR^mYS{kre-O@5kB2;N8GM#%@r!qa*QmGX&R-?KCcxU-pJQ$ z*<;OUozF0IZDhS`ZKkoGlIvaS#d`0!62E7vJKOYoYdv4&B2k7vKWpwpf9N7j3xrMY zxk3M|?0mO-Dwbw5ONwR2OyN;oiYmsGj2tr07SLI=6tnCdm}l*6OVlJ^ND(Q`h&rii%e!&pj&a-!eqM>y#|0PPUrm3xr|g`Nn7I?!2gBL_BJC)az?5HB8f!W#nij zA(xhoc(K+`S?`6tfJ%J)MEF4aG>WQ6mtv+Xi*?-+7ZhdLG8&+Sb}` z&2PB*Th=|7o*JThEzQ*h=E;q^!vNMSwFux?gS4J^spTxbO^ zmQl=PLpO-_6qjC66rLNQ65?&Y$NdL)?zIgYoULG|y%k*AAdH)R+2tb&vd5P!FpHx( zBAyFMkALg6Pp5Z(Piv}|sR~AvXOI8+f?!UxSjxmUE)1vyWnri1>{Qt-v3IIqNB6i{JekBr2%?d^o|LqrAuKJ3&DDvI1)XdE_m)_$0YKkff3Rre& zb4uBR`0IwFV0YHZR^{yhn<4UCD%U==saG;bQ!vNn_5!hxiaFYws;Md z4Ry}O;X~yc6t6Qb_tzgGJ~=6fw>8|3thnGC+~S82%v{zEoFN*F2eizWk>9R-Q8ip>dVegc&L$ zMOx=XW4*nxbHQZCkg0H5N4C=7;TdlNhaIMg1nZZhWwWMFcX^L^UAA*Sjvr!d#o@Tm zV{~SG!&@=6YR2vvhQS}4qlQ9(&wGqkrXBX-v4iV3jC=ft-AMqWaU{;&#-lc{=bO-a zVV10z`x6JA_RjP}T#;4m47aA7;)XUsv4V<}RZ-xQ&3fGjzNsA|VcYtau;Q_p0Bu`a zLB;A!KMePzZWS>$mc#!_Bp^5Mr4ot-xed%BIGG~nd`xp(-PTRVN<^Tv1ZS2wNJDFn z!UiR`u0|S2b&YjcnH$C5>MJVhD^frC@jdta_#R<}12cm@k9(po2Naw?eSbF3qCrd5OscWb&EkUrssB6G98EvS?1PK*I+f~+16Qwy# zLunA*P#Tr-qMfxYW$UHsbz`@IsjbBhcKX+`ijEcFX6LORq znk_bXO*r@;;_yGyo7yZ|V|i{+va?z;Xm>((cIJ}S3xZxWlf^az#uhJ{d`>Ah6wZE6 zE@SESb;$mMcW&5C)J+$B*veZJ13|+z7ON7sme^HXU0qx>L5&s#BC+XFWt5Qz6RU8^ zD7`!w@K?EEa0Mz0Vt(J*V0d59|~DVh^9=mdn#`h#Hj} zYwSQo9Y;ZjX9x^#c_jn8!DUpG7&soq)XN5Tw29LUOu%eq)WVu+5d9Cn{@{bFT4ogK z0o|wTuS~}{E-NSu;5G(#E!KIHJuy$+IXG+ZTr{PkJa0n7ylZ^f+1`t$d#XHhS0q+F zi2P%VbmR@_uZ$>%y;Uv>alwxZl_?i_a71xV9XL!rFOpZdVlMJbzsQ@N?ZcxV{IkzG zdEOd@yQn3&jk51Jc>ZS%9XumsZIfdXt3~>t6SqrJSFsspGfdte;7(0;AnM9K+(iwW zrC!W^8ee@VXYeydvLX zRtf%C?H*%**f#?2kn>zIvoPSh)tElIxY3+qc$DeB5r${SkU8AZ+d&&Mi^VPGD#Bop z^GmcYW3d(I@MP0~^*0tNwA>+g%qonex*~-lhJc8*^U~eE%JeSwr`@m&&}KbW6bdid?pzoGWUT@(rv?^4~?Q}%Y25Y zK~A>Fb0QO^-Sn2vCyXbg%?&}L6~5HbVHFuIt#t8puxMoJZzGF>pEakNjZ%NddJVlq zH@w1U`ldz3g$3V{f(Pu9-0)pzs4Vo^iQQOGS69%NpP%}-RN}d9LdTJgX!GGt*}efr!_aOkm)Fs9GQ)uwYhGnVrBP9d%T7MT z5yjP2seQ8kGoxjiOZdH$&UwKT@OlI2Qt8Zu7d~-TdHsq_E9TT!#=_naW=PY<&DD$Y zo5#fN6}1M`TA&@yO)>{4hkSE{DAju$FkaP5kfbI%SRF>;;y5M3EMWRz)zmDR;vyzC00R2S!0 zd4f^wQM${9wQO(Oos*S&5($o9$VzIn^KSI3HHF%VTqA=c2D4*YRc@tsV@+*#yS#G_(&yuSt z24y-_SDrpraSoVy;11Qhu8)@2(J;%aU{P=ask$0}(BpQ=uXsu-pCP{=TDxz{S;|(i zejvA0zYBw@BJ$?XeDYjhAX*Hz%G3wzr-^${iwHS{Z1<;a zMFTg;qlmJd2GASjQi5rub_VW}F^!bK4#=(hB5c6d9~774bZigK)0XQYeWBwnN1WKh zM&j$=a7Uv^)1Q&wvHb>_Prfa*U_-6OtY-aG*?}S z)aqGx#$t_>Cbug;#71s$?V zS58`TZ_aghSx+jqKo73EWNuAs>Q7QG(JV`RVf4r`6IU&Y#X})StOuuzw5*Vnjc_KC z-fMCF$HErlmpt?70_?1>plj$xx(#!^T&oluFx#t?v$JN?Hod2E+(JAD<85VHS>$j; zOs>3gws`1JHlyyTJ_5f8Y%+U)#z{%pvcKWG-y|B}HP8&tlSTjlR^(kMP^4<=w=9J;#OuR#96p5gSMlgHHoxQ7q?_L|_ zyE`EobbY(MyJM#nyS#ck2y1)SkN9I(Z%gsP=I@ z(+RBJMT%LA&Az<3?sT??p*rk3l^|Zz;g5f@k3ESR!_x_uAJ`~CrA{(Uun;wg3|-vp zIlxf|dd$>;8a|0YF1`|yXIx#11v{%6_s>&kCeB=W5AB?BIhLp0Brv@B&e&-p-j~?B zX;><3r*L~3c&pY_QR%{r*@S!iI(%hBv;nqvZX<84uuDNANbuGWuN#}{I7MN3_!(+T z)}ob*vRb5nc*K!_FPc+ZkQ4OIwxk5~mp%^l> zcXmjVfUDrbJC4`U|35@MYO(8@C0}DvTBvfY0diOcrAY@pz=@cva)OGx+39LiBPI|Y zbLmwgy?iyEc3`q!o?eM~M~C6@UFt53SW#2WTHabUxxf^{ESOx?x;*9cq_X9v)82a8 zmiA?GT}jI0E5GG+HTbj=zZogSlYx@b?54(vp`a@=skS-0v}C#FYjAmMWWF`>`;`2C z%UO0{^V#oUUw~y3z`mjwYY*X7Yfv=d!31iCC$LdxqD)VSNFljts>DcN6J40QtM%fA z-U81A&r1tc)u$LrtD^W7qX|hOYP#NRJYjfl$z7Q1T6l4*2V@0LF9Z<|yj$V;)G4_O zqvDgM*JGS$d=p0apiDaXFmLCvBA_|A%UC_S*#`;-c~lDx~`JS@`h*=zo>;c(uC3F zlT^9R$mZikIhsBMiaX_s5iCYz2}COKc-BTQfrJI#)RD1Q39qL-Zg{+=EA=Wa$!~_> z6Fi<9yx{_`OYXrUAhbFWDQQVd6kSB5koF}Qo&I*98P1d!&B#``jUx;iWJJtadeVC7{08KEF1$Q+ z)v_!K8J-lM`3BeF<9DycH|u(Q;y~&ba+aMf7akZU=)KWRO{1|=!iPt{EoJ!;o|9Ou6>Rxhx^iiA&`P>)Eg(_|=1b+X)kl2a$au0K)-Ghht~L z#xKnYv-Mp!ktW`LoA!hQG2ANtvfLjI_}xpQ(IswwAnY&yWjrqIYofyX zVJtYGHJmG=ps$9USNNP!|OXrlz*TF z((PI&+}8Kc)C$(TbTiDege%00~G&=R7>Zq-;Uw&k?eSQm?}`iebsJR@;mLq>n@)pMA16@o}>xZCb!VFXd&o84(hEA(E@Bh8={qbrObL$87*#N zdo!G=cN5aNRe9e$u`Y_-yvxC6Q0{W1-h8mt=2=6UrQ`cq=lb3L;HYV-H>a@*&9v*k z!Gc;L;au}$He5UwYvb#7yMa1mu}DPVWc2=X&WW;?vVMVNoF6Aw0;wYM0flP&K=Tp) zur9x`we`t>b&!5ZFcEh8T%3=ehkNKx^6FLG#Ivoo>Y6 z*p0fc<6}0apjfO+Ht0CniXxV6n%QmqY4UiYiF89c4U2FmnsogKnlzO-Lz~)bKktz} zumC3xnV;%D#+Y+v9dXCU8BKP-@BV;X9~>!|J9J*~RaCGpI;%P(cbOY`&m46C_{w}Dm7dAWXnZeAp-a_U*J zC0Aa#BzD%+$}HnCH>iLhvbh1w7O(@Q+#bxhW3jvE_ecEZ^Kx3lzt+TxV;^$lz}mCV zO&aucoUDoSeg##n#QNOmy!A71aAFJzM>Dz8FaEH`GiSVR0$+Z&<`^g0`~$QiafftL zM}j#8;YGMYTof+I2^PjiO5>wFW;wo2ZlgB&Th+Dzetdb3>4~k8+rxy{V%$L}By~!gD6q+b`QbZ+B=bO4tMbGz^b&& zvdVZtMRE0nS;|-Owo5OK7F3MPz4o?oljf)1lGf$KbSLSVI|BPYVl0nFg2Bi}Lv|zX z$S@h=x2n0*#hLw4K4CF1Uvh7;S~`9qpPxK9#-q>HacVh7JKv+P!`y>U8G?Giqs`IR z)~|Y~PWQSDZOn$nW7y-|XFQl{_Xdy?v(y|t>IvxUbZLF1$+`zb@rFz!xerp(L=WN3 zXB-ONv8$t_Bi7O3G%?adCo6rbt*ev&$uE~}@2vD}_&KBar{Hs3eKq$ViU>^V^+dj z5<@iwG_Imj<=vUw8%P~=6cMwC{pMi`U)>`a(<*+;CvFO87phtQ4u6(I1!ciuy_X+ciLod zoZIJ}qwxL|qhqLKtZ}#&l5b#BZ9FbJt>wDMm?T`$SJKYQ~vOB6Hrp#XlIHa)CHa|44eVU`67L1g7a{G zeqn1OkD9ugs4KHzq!Z-CQOCy&+>2L+=(zST zi*OGkh072R$;Vz66;cmlhiiFzDwEwGboOl`umTuILUs?>W$;8^U zz#^n-J{><()%mv@n&QFFfHl>l3f1G$eWuHuldlL>H4UFo^8!9qaeD(kp}2g0pMkS1 zFD^(l-Q&{Tp|DFcbeBgra-y2>839er#m~KP4?aAzU-&&PFCGnfL)n;$l^xP-SR+>! z{BeQPnd83gzvAt6@o&r=-uV6Pf3BMr$kZ)xR0{J)#pl5iu4*n9er%~|y3g%ZG`Bko z3rf(7Wx^Nm8H(cb=V@NU#BY36m*LLUG(UcQuSIhVZVy9qg+m^057ltvHceFxA1ba0 zU%=~5=fzKg6g3*bZ;=h3j^CLJ#e+Y;P$I|9d2-QgRFyLpfD6vk`MbTR@t2)&rUJB& zU%x-u2Sb=-RA;SwfgZ!3lK(!>`n*qI6_;8q6?up%^ZQcyA0G7#QG16%4%RV3sygee z9Ia_xKjUcq*n>5PFRVaKrwvIlAhJTifZGfkW(2CH(XhwR(b0aaF1qeV zlLZ1UGh+A(F{cSl3#)3j&|;w=HZf*aP-w0|mg&WB5(C*jFBI41%PDZfo^bmOcVUr+ zg9H42EH~=$C<-!T!j@UHQxC~Y6#G{d;v!Yc(sflERp2+k#qgUb*RSbV;{31zb*uvz zJi_G);%TMs^+v;fK3X??*(QX6NMoJ5=1CUnsZjnYKwZ}PNbt-V3C_thl#)nV)psk~UlPw^DI?#53ZYcfiS_y?$Z(hVcLyGNv#D;}vU~uqRyS z2i8m`&q{cXm6nNIa#<@nSp-YIq_PIRVwpSislt-f*Ey=GIyR;g!i$K10f#qi@C70w zNaho@w%LYSAaLtXHXZur&3ts z?Z1Ts0Y#MS0Mo!(o#05L1y$v0f0VxvfA_`6396-RHRE-+^Y>mN_#-=b*rQLx0LA6U zumZ2iQQS(-L`|ER_J}DJfuf(~1#fppvm*XHjChIh&w+?0N6oB@&|E^InbQpJj_?r}u$hnHrwos*kQw*G8@Vr%l?UKDc#Ht=0jK_Rd zho;NWofgI~TStnT5*SeF+a`XIeda(V@5*o~WB-^y&X&;IOuo8o-t}M)lir*unw%g0 z5z0|9so-z!c)ad63rm7HkNAIhdlN9p%JN>kzEgGTEOlz%x|Z&)>grm0uj%S-_M&I& znSr6f0S1|1vpFLW9Rw5+v02nX4US+06QXS}27SUb3{0*i*U`~F@Kw-e}cy1L!_dZ~sxnGYn^*VY0*WA}9Ll@N2lRn+80Me}4MFOGT zQ8hhO_vZnxA}Snn`6dx!sJ&_?FAjR$F$DYY{PKhL!Od~QfB4+= z5yO8myqQkWn!nN$Y4+&icEblNm`Jog8NTdJl>ed!=FWyE)pv%%horo2@j3h8vbeD) zc7bjno>jwZCW9YuK4k{DZMFUDSiV7hLEfol$hQ{BLfBcW zUR&nm*n}nQb6PMYE^x}phX#Ow>i^Efw_6DY(;Kg!^Z@rzj(w<9NZ%I2ZDTmxjPQ4Tl32v|- z*mBY0iHV`u+loBVJLf=-t@!5HX3o_Xxpq!(rvY#SW=H~n<*^}#G?z=nJj6)K=2Mer z)xv<|=_mf`L*3K7ylJa!jaLgkM4C23T+2Ohh_}sEr`2(>Gv*f~TzbbFx@US{o|+U0 zWWa<&&2~3;$06Q^oo0_${!`}?;6RcUuwRzOBRzvT57&JfB$~~e^6*IN8mc6b7FgT% zm@)=Os3YP2_R1~=0aSnVPl;nY<~s(48S&pY-#{yOS!ntAeF9nQ^+4dApZx&5gS_Hh zvwPa(@GvH84nI$gzXw}^e(VM4W2u%QLGYeMiOePGc5{DXNN8FI~v?KB%P4?$X;SUkY(eH}H? zN`!(2$))sQ1rJn$ItjguU?>3+@!2jv$*lok!CVCa2@Fk!!l4J9U^ocFQD4q4Ki2q zA_QE)h)RfzDr4Y)Yi!`s3&k3d5-fP-3S-n0HC1Aw4w;)nwUU-xgidz`tH_u#whbcv zc<;$XYzu$!C#p9UX?`P;@~XwK^{-aA+8`hH4Z_w;5!j=2}z;KEqzBDXrLYnawf6U{F<+e>6^5pjjOF{e$xSMLd zLl%rzg1SW#J4+Az^fy>89aH>1#+CbdD@HwsCbs41v+WNAd!s>Xs8{pj>jNHogTYr`uS7gSkLgyGQZ}P!D**%4j}a*I0uIOJC*$E?bA4akv9SAt z!SPuD_q2csh4cDeJ(DddaHGY?Bg*xd6H<0fYgGf6H3GfTF<7!Y2nK`B;Eo1a5>O1I z$z9OF(2fSBb>A!jF?e4>XRYy2(}U!?NmGWG4smXnaFfYfdBN6UAxrT~{ZRn9SWfeZ z1@T6{l0zg=l;hfDAg~}j8uhkk0YN&JHPgM&K7@ljJ@DC*zshJEi)1NXN<_dw?3Ol? zd*MM3!3~C?bg)6K~;0_?mQtl(56Gvt~z)&5=ek}p&3pv08$Hi zI6b=|LB>Kj^cPd5G}Uv8*fXyh^Kcg3bZkV`0d8{2mTvs~sUjRo=MD8mFYTM3@7pX! zNqq3%H^cl`Q6Aj_8HJ);|AlUBeE*$_0!Q-4%9CZlFS0j)FX?AkE`JC$C$nC#Fs=Gx zY+!m+S}Xr1u+<@u#*&+r<42yir2nI_hZ$+|hdo6lUP?DI#kew#BfBsb7(m!0iCjLl>UNVGmfO9W%D*pzE|AZt8Lw9H>bke0{S*%+RCPqs zU-0O*;W7KhlJ^QIhmG13VcR#U7ILiQyX&1AE!bJ@0l z#}nT5f;;yGd(=gb=NG>n(ZDaZ*PE0*Ap#)9*pLDzTT#)0OKoxfd(T{77Z4m|=hX zzDYBiV7PfJv)&$NPkziLC0chLm;cPC#0SuZAy|l9PMCph{b2hJNy#jQwI;%~qlZc4 ziup~3Mg>q0iVT%OvJn_Rq74{NkntKo5kich6p!NqP&!7A{PM6PFAZ_;z-^aY{0c?6 zW`B-4cbPW1xzQf1y`;yJ`RpclPrx+i=Jb>*zd)Rd zOCYLPzUbT&)a#mc;v`|XvC*%SqJ(HJ|isN8a6J5tfBe%(DX9V{89fI7iOo}y?Y?k{J&$}kUs<0 ztmV)^Zs@8vu4E?n(=-&&nMoRa31jDn3~Vo14oC$c8!{jWGCMsc+p+I9tUpn_W%?zj zi7=d*joyCOznDQ0-05;)au^cwGrv}Ho+Hf{I z?6>YgTs1@2>c9wpNt8GqzqInZnRUo48wv)GSDx<``Ml@EBGK=6+U+5^NsPVBfQ@Gn z*U~oMncbmIZ-|4T1}9M+h5H5JNz>0EHS0obL;SU5^WQiOjT_pS^|*K@T@cx9ZZd)e zGm_8qq7trP_^16d2{Pz5N><7rGcWHRl;N@7Yj*4}*S0U7II+0rc;J=AnPL%$tCRQF z*bZv|3(&|#6kJ@X(u}sl( zPhUkvylBu@X|cj!;=0##(;e^f?id-LFXv{nW6cM)js8^ga!3gPkEnlMt@K6HKeUP& zpYNW~mI4@32sRi}yysYV<6k{IH$PsOvqsdy{?U2QCzWjMbSa(lM+;j*5}&v1NH2jr z98d*SX6QzanY2#UGZ)-D>(~nnDxuRZ27`NrSd3b?ot^j>D;%~GrQS?X4_iJxnAsQV z3GKs~&kEzLx0Lu*H`uYArS|6A-B0_%Vc)a9e%%eD40;dSQWAnQ{{{Kr>%`|E89V|T zFBcXaCIw+OiPwU#tgpts+!It)#qaT}zx<$O#7zJ1=sg`Bme36$dyJcu=yN}88O^VP+0gZG z3+D{ zUMu3YV$)W{n#M^4=dna@h(ZB}__9CTaXIX7Uw(?;@ggSdmVP_pjl`VM^n)8$U`_ox zE6`QHIKQMO2|>U~#$Rm5M=?ZzzltFA2uMtGEO+>Mku?#~A3JS93`UGpHa0n;kstw+ z9a9lQQvP=s_C?3gvQPvtL<3V6tfdGtHYy=W^+kR|A;(C7FFyX(L7rwdw&+ zUiCZ~ihju-UHyk>^u0r>x*GL=DH>W__D4No{PRF}`FHX@+)M^!{CE|UmgS0eJ19Q- z@n5?oaND9*P}Tz7gUO%&=8+ z?C|>VE}#aEw!#2%5(*Vy7W+6nHH#3uD$G$SE>$RW#$_KQ#HSSKtXB}YYNqJ<*ufvj z1EKa%Wl0|JX_jGAWJSzK5@Zpsf^ZpgQBoMN&qTNb8Y6EE!I9uB9n-GX<`$4u;?Y}2 zL#Fm~wxLo{oYio}42|N#trsuMX&ReJQJ?%0F;m>)U9kJ42%z>?VBG^Y9+bEE@`QKf zGiKWS%!qel=(r>+fHvHQgPBayokN-1#czG%|ey&Ex@?mHGJ zDG3@kmW}Yd)?jDb($_fBAW*uJH6V*RX*E{rG?YEJAZ@~~>$HHS_p$(%?e-=>=j{XE z@d88=c)9B?*PGA=Iie)Cz!YH{1l|*M!0v5qti4@;Z)E1exlf+SXS$COiHYCQUGBJM zb(f=At^39I$r4ewDi(YcDzN*p&qSSyma*yj6CvyKR_pIX*HxSfxK}n$n}L{R#pnk+ z$Hn$p^Nak(>pQkjELql)^&#HAv7=nxBjL{~`jcW2Q7nO-=tV3H2}|W571o~C+?^~P zdbVXvZga1q(x8UYX0LN#f**5AMWt0Lka}T-&em+2&$Np0m~)B89`nAlt`s%tC9YS~ z*dreRi?8IgA_e-I)A(#xtqWb(?U{+O;Bbu3d^lr}WGAjmQ_#zAo-+l{zoN*ng@0lsJi@516$1xpN?Ui_2R@C*?hK zBYT(g)s8}p0T*F&y!8;RHPQ#H?{61e9@cbicTYvT=8J8YLx7s5Re^j4oMYm6lXj0g zWgR`&ari2#8h6Bp=}2T~OWk1~ea{T^v@d+$X;FZUf%!~4(r#$8-aZzO&~tptW9?G3 zANay0FyCGcp3()gN)}Gr$q8Un4%q(wKh?CN7S~Tat;cboHUC~q81L2++MrHl`XCOQ z|BH-##F;a$ptwTZ04chw?R{>$qm#37*V})5wP7US@_hBxaGOpTv|n@HI7_*6AHGR6 zlEg=c4GjvW~KD&#g|6Sk@!fv)2W+ezrQmXZFnJ z5G%6*o(kSrGQSxnCRxk^qjF3`l1Q<}5yCC9`_a~}fw(wToqO|_?whL`aTSr{wA|p8 zd-q&52yKNYq{d&s8D@ARUO#Z%cK9J2+kS1qs~TB7qW0_^+H=*F`^x)z)QFxnRG5SW z%%^EXd`%DJO2<(*2RzW8CSWVrq3;ePZ^^*OJasRWPKSlDhDs5P#$w$&bn-5R7 zF1L?X*j|M1~TzayS7oTvSNaa zKqnwCB6DX7LjbdE?8{(U5v`$IBHLv!uaE}L&QJ;$k|ZKN7vd%0Nr#;JH^Ut+dG(RZ zfH6B`4rGQ6ZwUz?hTZOAzG4j1m06>G#q0eJyF}mZF{g(#Pbz6`G8M~$2fmd|d9XdQyeL7`v~4=7HQ|RXGQ^w3j?qnAL8>$`Y0xv!4>hkmMhP8_P0? z5#fV%s$=DbLikZSFlCM5e z08)>{NuD(u1@hELy@WMZ28E0c;S&(gxQTTAt=mc4zg>%jpMYX_H=-vz5sqkY)T39r zRd*kLS4Q>sc|gBb;J1T)MVr+Oui+iX&(Ov~y;qVN>B`;^!c-^Ghn{3~YLt{rquObo zhJc3%L8tj_z6`LRY%*o!Dp!>0B5Y7OqUup)fC#`-ma9p+13cJHqMy7r%#?I~V)7}V zmm_}Dc+ea2K4_T!BLOi9^i`!re*dQ6aB$Q8X5cm}WLcqec`F(L^N|$p$L%Q~sOHHN zbT2lWg27FJYw1q=4`W9ISV#X-q%oTlwv{1Zc!6w_s#Zh(#adaJLr|7xE|%4=4WxyY z4m7`Z(JON&a<9DTi&pRsQi9(RwB8YqkHG47nO-XZ8$Zv!$!1^o<*7|1eEJDZvd_~-T+?&j*#2Y3_RmjxJgR5F?b*9@?9KPRaPG#yzUnT?c3#AG#1xM5#{9TkzPB-oYj4D~!e3FxjqHO9RWY(4?CZ z9}<?pL6DZ|AAwH(56P8__7oyTTUJw9KP_^(q&qrlHxqV5ViV&Zp z@||~!52B_Pw^%mi+^sMj7vho|$A_AK)^S2D>b%e8LIYbgm$Z1RfhIo)L_nmH?YBk1 z+-0-(-mX0$BvuUCfp*5vax9)~vj37;!H75C$E-Xv;FJOhf6ug7kk$c3^XF&Cl~Ek$ z`}TKi-+@ehIqPu8)*dI@*Ah_$*K6sBBZim;;DvyTI7Rc1-j5sum)?HqBGNLgB5Vkq zW3#aH(o1(PEFhgy-vZ4`o$bXw1Din1OCV}AnWr8kmNw9x4igTCaFdK_HGE54UfmPy z3vAsvcs#vhisK~p4D+Y8e35e0NO;au5KJLAg(n%!CA!7}fGNc3js%o*#+XR$naJPp_8amO@?`8B*xQSh zw_aO~0YvQ#n5WIafO7r~H=M5w@X5|G{#=@uflCT>~#{-`xn9kNt!II3FXmKqI%e_;8Rj5`$ueYb_|b=Q9v@m`(p!le@- zH1TDKS0RLefCmB%y9h$*_Kh1TMVJib_EY{-ZL${W1{1HFdF zjOjyHbT^}`o8DNy{odQlup^79`QtA;o>yZb_3@?W!Ny8AqTcBCz1y)3!}4loqxM2R zVdtr%7#I}kiQ-^ToRkrlYdgX;kZ0#?Ena|nG%ny{N#{kJxWFi@Z{-qml687hc~<>vBl^Xr83VdN@7@9`QVmc+rpHtfsB zz8DL9EfDx(pn~1qY^(mn7h_qRR8WZBldMKT*GQgd8F-!EIkZ+vC)cn;PQsLD05PxF zx{N+5{=@kKfA4Yrj50|deSbRrmz$^x-*G;O8f63b@mK#CHF$sKK;{Xm4h@e7bEILz z#R4dzIn)BM%T7%wwZM``jzcI3IF6RLV7fexmS*$R_?l=KZnpta;xSU*iv>^$4e!^y ztv_T3YyLASeUHF@XbM(mA&SE;>J(4?#WAlp=H1KNGp-!T=^4f~*p>#&N4$k^%F>^; zQMcVtt0$axFdnA4MKQM86hezC0+ywLBU1|yBIw7s8+IE-X&g{!#T^x}76K0f;c5e3 z@S0J~Ku-;W@o@t>Zs9^nLzLLmSh$KA5W!1qss(yDQ(S>n#kk>b{@&8kD)@}*iXF^? z#(EX?TWMvwq4cj<|0Lci<#mrvx{b_?}-`&e9?NHpJi>JHSw?TP>% z-OW4+^Vfj2Y$@%CugG&iej5JZUO*#arV3%&LRKKo%Va(3g)?8Fujg{K{oLkrz3#ue zgt0V&PG9oX_|Czpz05xf)CVJVc=O;D6j7&TL`cFVz@J3L=AU4Pz7{7 zDg(-yZ8WGC&?x>SSpeHX?rlpTnA=fEzE(&yCz2S)N29QmOZ;f_$&M;lxgT+%c{-*o zs_LQ!k8$U~ZpS}h-fY+oJ5D207INHxIXVbmsDTJPVoa$Rv)>v2|=u7YcoL#?@{V z!(>{kopSK5?|0XyDrp@oAQ@uss{qOPeGx)Yl-uJ-YB z>fiXUsDF2RcwA-7)OI(Dhr9b*_Wf$R!j1N!SZ??9nT}rHu)baV0Alp69q(UtezyLV zO<3RW9eupquJXo*Ec<>MxZ_lZ+zkPVyCrW?S!V~P*X%jmI#;{e)ZORZ*Lk(osrxtD zkn9X|y+K<{jSY_%+m8|$bzZa5K0MuSJl&C__h5rNoI}7MsHSZ?)(4paI*`NEX4FYG(`cDxrf6CqN5 zZxV50T|)rV8_6BCJhbnKR*T zv}b#Iz~^@(gtFW38%S^O(b6-8BZJe4fmE0*_QI)w#Pr~i0!ZXexvAgfnxWT#hMZ+8 zO6TkV$IiX}`g1Sk^ab_nAB0l{%df-|^zIb9)fMe?15=Z}VTS_qSO}@e> zoIzg))rzOtKwXJO{50mkXDF6vv85JcKdOQxXyQ^DO>xp2o7<>W;F#-M8=@0uOvF36t;{VW}S+XyGjy3#R zaQ!Z={NC1(uAOro?{{8}w~FP?NhjXY zJYvr}(VBIDYiE82n&GdRh9*4(pjr#v1D?Qw!r`e3q5)1H=5aE*So1`GDternatVsoHYXdQP$RjS$0Kv*d!i`*kaY3#HMX&m&9zMx9>)SEJn@Hrh)n_~kLzD~ zT;n4Y(KQXXbiYf&8dB;))@au+CVDq`*`ICnvQGF9?$*h-!d64J^qsvI$Z-n&2npb% ztvx$w9fczOsAcbL?b%%&Mb^=^_yxw%&ij*j5(=Sr!Dz_$-6V9t*JqsMJ8zPD`Zf5e zvwje40s2e)4P#b~3|h&A5Itbm@1^tex?a(ZbIvia)7P9is=ke`*>|F!JNpK(!ez@= zkWQfU(+QbV^2>KWhJcrr@2z`%*jdMYqRc3>`I^UkzNC-JX?Gm(tutTIFyh#^-mD?$ zAKC%I4w!w+J}sc2f4JL$TY*p=Wr_XCr2`{gqX70c8433W!x6+anX}UU>4jV=lPP8F z8e9k>IgNN_VZ?Mqe0r-d>H&;2BB4@xc-u3$FH>4ury!(Zt&tkEWt40$*4D?sV^~(r z?rmcrl^RF|05<_L>E97&Bnv1{4ScW1y4tc39~3_{*v9QH;Px_GJVU#J_l-au0qq9^ z2$=WVHXh`QBar}9A;@(#YnS5=`(8X%lh5N0zBi8CUu?1{0IB+X*X#vU*?Wzzuk++1 zqdUzZ%NjCwj^gT?6Sme}-QXGf1s~qv8To=&u;KBVG4?}--s(@!xX{Fmrw_q|ymsHc zcj@F5qQErYjD7P=$)23?C10s|W-5!F5G0Nx8yvup24Dy@+-qr!1Uyy&qR@o^Sx|!r zg;E7z!4n)nei7{!(N8*|=67#FfB^|f=rVzs8`-YoMg}d6H@*j4#@~oK-cGi{c&C8y zQOZ1^qfCz{`7wNpPnAzt;Y-%tT({{nJFw4-g8B;`I6?I8G&^A*nWnzwg+JE5a_ z9p6S9)HDBx_mdq1MV)ZO?Mx;1w-W`Bsp%h`G#Bag=*jAxrmyb` zPhB)RIzN;$#A477%DhwN5CWY&Nj4djHx=-JmkQ7QA}tIWcV07vf$1b&B;o<%L<;_z zMwe*R__^9-JYe2tS%)#VMb5g-4B+6f^%l!6{b8#}w2n5o*eW_pJ#E`{`;(}VBxvk9 zp%@gGU1M1@L?@C3?e>WeIkju9w(88arJ6NxBugn(h}|OSBjpW*->HdwMZTt!CITjD z@%w6b$J7(Nl!%<>Axa1@aoL9oH#9w5P$-I`3~05O#c1rWxc5 z_$}0b!WD%+<=^onY!o2hCZ+5Ia2eNYClN`&H^A+fS7f@?Pne!YJxGK#@CDYs_>Q5f zQ8lK&!41>%k}n|KZ_r~XQH^`=J^Q7Xs!?MA;rkB%u`dwt{qbQKbPYh?c<9hs%DeFd z)|Z176&p{$Bms3pK9Ul8)oUZF!;FufuY(BFHMI^K9~(J-gV9gpq|bNKt7&)}%WXjW zuRwz3ht`2&@4ISHEMjz2>;T_vnU&MCTgfqzjNy|u+SKy3mQ@F{qQDNh#tK56xj?mH zD5WV^)IVxY4fc&^GUI)NQ^@RcaUmZGCzJn(U)T!gQp+pR=nBS*62h}wYY|f*J~)*d z9L!A(#$wYMX>Fhl76~>}?%_K4zcNl3c|-e4-k{`6>XBLrq*s)43;O9vWuWhYo98ae zOh?sJ58@)`>KD9fAMw8Lxmkg2;l%kpYE*n={?J_?gY{2wblmcpDJwp)qdEXC`EbBX z(M)uP*A4JnPcSDQYq8?epg`LEjeGFD#8ZV$n+nj{&-(g`=}ak_?u~lmX>&k4lARo# zJa2;Z^@lcFR`{Y!shA1p(%FIG?ZqGN%Ug6MeA(`&s|d2XP_Ud;(G zgaFu6@Ert=MM)b(-S3|oDqcoa3?yIVxlr8L?K`-<=MZ1+uk8;U-ZoXi(5xF_x5i)oZqafXvthz1cQ(nJ%hF^V7^d(MMzO#Q${m- zHj20r8?Pm0i$9ZX=CT?8(}B4_vU^GCJ=JQDSOLfRk*-B0AfIYr{2GYH}6m ztsoD~Jz(7;mV*Q?{X(o#e1@042mu{*Ly-X;;1q`x21HM*BgZx=hE8!DgbPs_9@G6A zqBkI6%k{eD-SVjB*B_%XgDS<=6&sKUIGLj_fYm#+vw2(O2Tlv$1|k5+0Ip~qgS=}U zKbLaGgn3?KEg0VRkrxfg>9Y3%aD)6?Np4}-5M=VcfU#*$?G zh3is&1lF1SZx(%hh^n)9dOFwVYhGnpFX0mm)3-h9OMnEi+uG+_j(d|afEJ>l zT0=Ucn1*xVk2IKMIBFxk9 z$H%v6>Zq}B)7Gsw?Neb0r>Rq8Q}}~P9_U5aR?v%kh&PWiy+{aH;$0#dC;#ty)>&-d zfW=0ib-qi|wuG@IqGm$$Z`Z$T*M_eG|LH#S3*}X?cghp(x0d3EikGMYtPc<>Hl428 zGy@4%x}=SC4%MsjvTHED*GTDM{LToA9}}G?xTk#^`h#HhJYWx55D;^cC&GmgB#DS;3#}t}$K*gVX_fNZdMy0r(utRWYseCaV7~6th{R0s#2_1@ z`Ld2@0lvj>^JH|E|Abf>mBVo*B*IrpLGb7XMZ@Eo&`sQWm0?_koe73%T#Rm1%KcQw znmvc=LMYV{PuZd+;xm~JmC|VYDu~l-H+tR1A9;?|#!7O=%+bLgM(?-HJ&4gM)Vj_w zG6XB+y4c{lt>L5*Y)Mzc;yhc`uB$-ew$7^g@z!Cx@E-`6H#rUpw2jboy_Rm`OA=(n2OAkv6W=Hz{l9sX_jWC}vcrA`CGCTZpfq5{e|Unu4*RxQ3WO zY^N=CHk3&igbnB#rh1`!sj`8rZ3qN2@|zGNunsfwU%HjP*L%H>u({7+HuuQ~P&;Z{ zy0lgzoHI0&%&eBY2Q{v`(2*zXJHcC6uaZ#tGJHs4~Kd*AM%V>*hpOaW8>KV{l`wT0h$QyKjzNp6@V#P_=imG zk26_VVXJJx20t(O*nUd6Q+$>qXOJi5-?;t`-)}i!P&5#DU~J`x__ilTvgLn)bqmW( zRFi^s+io1?1X8M(z&Nx@$S4;tq+q%wG|G1ESmW6NDjN%Ct#8K;(Er2*^obd@_0(@( zABZFo`7WTVy6Fx15VTwPLI~%g>H);U(?g+Wy_$}c3_pIy29UlW?+^F&^jUhTWcd1f zdc*#lsfF`iZ$6@%IV;ke>hl?;l5XK9d2PV^%CILV_2i@+Vf%xNI14T$Y?+b#H#}!x#x_1PuLJYt6hmmo!5;Vhc-++#h(nLTX z@WsPsu#6m<;aoqiXUah{6gLMDNf_7t#(*yt@`XxST<@PwR0|0;%-eT&)x#e^d`WMs z9#lhQ?34Atl{NJMYG68P9y}}OCjm!n(@B_WG}9-eaP)D??2v!3(T~GE>DG(4)kemV zm>?c3-TLTHf^oM;9UrM}s~_))1g`o$v3Wtv-+g&1WqM)*L$&$Kn|BYyJZ362GpgKh zzR!qUu&GA44Svuf2mApIt_Xu}k;(vNM&#P=&gU4S(OlSVBx6XzUUJ9p22n>Skv0e; z1S<*XDQayb3;;tMJa!2HQ|zK^(2q@DwC?}VVKU%642qKkC5NqRbrvo@sA&%Zxy zko2GBXigx#5v2fORUw3$&|*4-a1Y?F2S^|p*Y>eMlG;VNj_#0n5poe5jMzAbTmY$e z!NW+9?-*i@2r*o$2F#P9M=_NiancM_OV%*UCSg4qFo%B-*;<6NsG?LoNK2|551517 zA}=%Y*4N$JIHMXTiomc}s0+uo}ybGLP{_vvC4mQ}smf9abo0iCrN3 zd+@~1l#nlKh3qjT+NPJruuEzSaN8OrFzhOeyO^C|s+#bLJWIsyA#w1!5j%tc(VZh} zVWh={9Ydza>>=By#ePtk>loZQwilLsJMo`2hR`In>Tp@|ViB>O<~;b_vQ;KOyr zmy8z3`5ei$2ecm804(&SNllUNTTskWk2Zj`+ctiw+~L=U#zM(tXlzLLr==X0@EZA2 zQGPO-58S2LIAjqcIr1!~DI)6*+XpNh&H4~zu;BV${) z?42*0cpTBQ)|FhPAYQ2mTsx&9Zs_RcsF$!~CO^TkF~A78n5RLP_JarpYQ<4f{Cl+d zbR&wew$T$IYtz=tGTQ=LRga24^+X%Zr=!QCdQ}T-%N*S@=?kBT5Y0$*$f|7%39bXw zekVzV#mY{Bh#~?=5=RJ72!=}b337OJD#yofd9QVjP)w+6yv4%7enio9?*(>ROCmLw z-+PM%gtZshPt^W{g`!vVc}zu|W7{$`P?yiRpk(WSBCY8yIbjJo$ZlqxBhjm#&){&R zuXh*%4Db-Vd13wem+r(A2anS?Jkoaz&6YNxnC>fxGC{HyQ1BcpLJF2TWGSF!Ff&mF zd{!V+mRw{Cm=SWVYgi{O{4HAx7XB31FY#Hi8q0=GpT@_Ju(ShfoM3l|94Z^w)f?Sy@sjX^sWcw z>EE29(;J_-X)E1Re2Hcn-oq~kAq&xp_O6@g^wi(TQ_szxrGCI9r~@7bQ5TB(x=xp0 z&JSqt_#=b#YJZg7^&!5GnyJjFq4QZh{H1&X2yg{BQND8LV-AiY8Z8ZX2l3asZsA)j&4 z{6$$$8Z^?un4VGw5Vj@LK$+nSh3fgk*Ijq`rw1Z$GZWhG=}>57AXErXZwZBpnWao| zdbgG^-}Zz^r*|zuh#=+EZl;w*W<$elID#*C~xg#47A$WFg*j(1-*GaXkOW9`#`MmK8#=T#YVZ0*&FiH;9t zTJmU@xC3iyhU7{tM}nI>{}qsL2O?q4dI6Befir<>(5rAp;dv+5Ufao4ab+S``$fdG z0^V{3%9p~vh;C*3_h6IGBINk*M>5T&HhGX`*VSjjY5zztQq&8BgKp54kM{fhW6|D` z9?PGONLmNW^W*I0>d4SL>Kv^B$ziO~m*fvl%6=iMJ`0Tj{?G_4Op1&tDGfPAXoAv2 z9s7NRMn~-|eNR6&k$nZ!yHfTBEHfhfSHp)%{87~=X98)igc$T&JtMu*F~7h6QBcJ0 z!NG!Fj08vg>2M^CP%KWRzsBuDohgnT+ZfvY%w*0?BFTSoWM$!6(5%ZbUO zzgC|`fBjJ*P}SkPUIZAIp_XD=8Js-kDkwDe;*#;cApIdAF(r<%dC9lV{V0v{N6(ew zpSv~fZqRT(ZH`Sh#RAK~7NkYsYjEW?bRSIfy1wby<;=nm%_jNb(B*s!k|)BIwx!P5 zK55%6Jm+sZwx(r}O*W;(?&mU^8GVnwFKtlI_R>qAwG-C&t)s0>A|gEcmD-9xW6rmz zU#YFO*79s0wUw7=>j#SQbmsGj4e=NxSY-Q=f*tY*ayZW-%c)Bs*)@Tl*&0+*g<_!u zPO#|JP)f=xtpLAJq-8BYvsUvakq~>Jl$@ExzK9g9<3cQ+_Eoo3-{=0g+x>Ade|@kp z{k-N61_Re8o+qv|jF-CIzjVKp_7%SGEqtZ<4!%$1b{{_cM4fyvSB_4G4qi5Sw07|A zIsD7-$k+P@u+6_3w;Z7I+vyJKKfr)bBQeomBIDv9YR|z$ApgJ|g&2pNTd|&kQo`+| zA``zbVwt|h($}NY#pu`lk>1RKLCvim@a;aR`kULN7u@p(_h)+}{;x-i)6s7fFX+w0 z48^Y=-0eG{x|{d5;zfG#eez>?Cny?_=@4d6%>7dWnNa>TK=p~4&uyzgU(0T0rHx~q=FopJ*Ku} zP9to=_o2=CSlU`NKL#1UX!Q9$*8DjV!_Dc|mp|r%)e6&WtLWPm%u)95ElLGDb1+b~ z^P*(G2O<$!^&~+gXLzVLdZW#yXm5jk#7;$fo37sIVsCUM+FM^fQAI&-^b`m*b<5G^5bx$;za8X%g1+E{wTbXZTQ@R(+<`E5}c_GV@8rZ zcB4pfKsQ>O)RWl(#2;|HTEv@m!qDD#bbkKme4)Av> z_(Rx#7&K7eSv)d-^vYe;USbe>tGhxWzZ1w7Lv62P>hmIb!q^H1Y5gqY&;&q-FlHg{ zdzyH!#Ei6SlCn+`h%%(4+vAvQUff3t39)Zk`^me>OH#2EUptm)iz)QBpk@-)LSaCCS`q?iV{0cOV_&?YQ ztbqErbJNqZ#{8Kjr6MLyk~Z-eGD2@TINdz8?eNU-<@2Kl;yt~UxqmSBj!z#vylvt@ zP<78=J~|kht3=R$KLIWDMXa?9deG6WI1wR)FqcEi#d-=BJzuO!R%XPrpk}rAzJdP_*v=sF;>%+ou?~W1> zv9;t2N7I3@d` zl33Dgz1e3lr(r{zl-AGE+K)(U%uIj_Oj9-rp0Xz|PjOj3Fft-WzcO%nKA-<+qMnG0 z#G5QK(LDu$hxXB6Fi;78HC$knS;~m)35{fS*OSBacp25$yc9jFV+eo5Z6Yr$@wVJ9iT@*egec!i$4!I z(n5hCiEpFm+Qk)*+vEA3WqscgV6&P(p@mK{O#;n(IsCRj`b*?hJ#LYKEw2HL4f%tv zlYn%JIM#|XxX!=8b9du8il^vgrF4X=1v^^eS-nymTyKGPl_lDwn!;2~CTf#xU5$BwQ^&*^a(6OO>TX{5xy`t= zh?lZ${Rw+eg3y>!aG)j`enF!vJ78UbFf-(&#_Tct#%u%X!j3?orzajVkci5MjJ_cR zy%%nrc$2!m*{6zko~36xdJM@KL%J?>MMbD)Z6P`o zGDLgIaP?joRm8&7?&9_kP~ZrXPSZ>#NB|kBd?--hu`O7b#UukySfqFs~YtMTbMTVldtb;{SkbM+OMz!~y zmKdj3R#wC^ZB|;~5CE^aytK69{Mp}WuzcQu6_EHvCtxhw57tEPA(*v~L7GW(b_7WV z*3@Rp+G;2VDO)bLl9`^);3xLj2Uh|?z6J3sFER{qJ@#8Ksa-6V_})-xiSNy1K5XxQ zhG>_#+qejO+-*F5aqSY92Yn!I79}p<)|!JOkof>rB91F?FW3J04z``KwI zKxAn3D$w#Wej&yI^;mW^7Pval9A$VWj`D8#>d_@0Fw)D>57+{C!r%nA#;!ln0sKB> zT}|;&DBh6^bQ8%$k))5m@jMP=QYTwr?CJEjxb7{hNmY6F_uu4Nc;CosP>}% zP`#MR6zf0Y0EGgg$a=jfmSuz#SVGj1H2E}wpA<7s6Opla9JjC5Ga#-Iv(0sh*bEfr z!#L*Ilan|-;By5r=KMV<(R#*TDMT3>RPrSqQJ4 z_n7tFb6&X4pl8m|5Fu~PGomI=qTYZ9v!G=`=5P;jWJ1~a4*%rReia!ewGc8yHt06Jc?o#JxRLFkWmV#makLerQ)Pxa-i% zH<=OC_A#ZH3I~JXUbsuK&9c~yvA_%m1k&8Z?yxGGEaWgOnDe3nGrQAT05*W*FNe=V z4)!Fca55}lW!HQXStA|~`JOhcsP9gier;GBgkE%y>mrP@!@JwMsaZP_+y-nNkF^YQ zX4{YvRY1A~hZb&~FBAAKIT2^e|O*WQeY$uu^GbKp92g=oP$Q$^G3rZve2f0IyhOs=c zky83yE+JqYk)SivN*x)e($ljkkA($<01ts+#OSZY+?FRbJC)Hb5r5NBFn~wj)ckrd z912b3Q^UGyrIM=0rx||=A}{MNz${eLEuRI;r1AjFMF#^}SRGz*lkTp<4QE9l|h<(L8!+iTM3$n#*R_v@^NAm zFNhq7Twn%HCiBBAaa;j|$?WFIz)Ok0oQF~5_rYN{>SOTxMD2@p(%P~Z>x>ijs} zm+eT^rKiwSkf4!ISS`O{)BxbYmRxfk6d|yI_TS#8LnRQ?o@W@(!wyt`U-M2g zAY=bG?_7UX^n^@|C*7)D2+Pw8rRpIYxR>P^8(gvL?8szbqav+&HkQgDwF!C@qNa^R z^s^hvZ0=*NhK6B8)pAnX@jIyDhO(R&%c>V{r-8n`AZvdVfK9@+WxzGIg?dhPbd)5v zv~)WO(au^&s?zUtS9QZWk`+FxbhNvw%%4Nzg0)VxE+CkbL(A(#?jQ@qwi(K7oFFj> zHFRcq8f$zK^O6d26G#_eZNylaB5Z8CP_xFVZ0EFihL^+_W)7sI%|D2y>(y`)2H1&+ z<;!jXfhw|tfqor(JzA|s`5@;X%*xakzvNZ-4&~7FSTvoEj%_-qU!e9z$zCPet6r$M zO(aVhS4VqC1F%)mLb~G4 zC0S1btpVOaTUl!c2q-P7Wp^O(CKnR~bf{MG=}akiZ2!Jv%ItohzS9gTZpCNxZ?2T* z`c0qWQ9|ZU-M4>MIcIEq9&uC$k!BVPQ7jfS#mEhNu6n_4-=&-X(C{nB#%qM|xc8Nn zxh>^ey?8KW_=I2a8-KX@h&8lj`^XKEQo6W^rwtgF5guAad&o}15B!z~G+~Ju5!!CV z(@cUEg=rZe3aPRsfLms>kH#{LG05nmxkQ#1v1-ni<(v@9x!fY+;AF*8w)qK=LO7(p zV;$~iFke)%$xYyOwb(52v~uymM3FqpDKNQ>7UD6|NdJ7QwETb(2{e}j5kn}!aBN`? zP=9VA77i*z*xz7U1j{W`xPv{rMz>t-i_>}Bck!0dT|Gg_2BAOxFIx^v@~<+mZ5v-w z15t-4$?-urDqve(%3~w&ydNh&jHR#h=9hft`PBQp1*_~0Ym6w1iC3_dhB-bc32zH~p;evo= z0onps9kd;`fbJ-PQj`PGfhKHUZ#yd?;+oSDNZ}%wvtj>} z*2DY`^aU1C2M>A>{cr>j*r9x$BNvJZ(#lI?cSEbu&Wd`j9BKnfs49N;?xE+-uYSAZ zF@5f7=3$w8r8den<%hJDWc2%KRkgjpYG++pDrtgSxTdB5Zb5XSik5P`{BoBLmV=a+3YIeXXz`3!@`&TCVooT#3hT3{`gx`1ESMUe$dAyQ-lZC&Ke(OFnWmurb`p~J9^6Ipft}t zMbo_Ul{+-!v6N?z0j>#s+PQHtj^;WBv8rM1+}1cyy@=c;{eq+1cMJ<0Z6?*!@dK37 z^Aiwwb%?F(Lvg~63I%>MyBpym7h5%H9j>VhI7YA}Yvd%)7DHu?1is!CNoA!|*!7FG z0aX;_y6P8?h$|qW0X(<4Zejr2Orq9lsBM>*S5JbL0Nb~;B%?l@)IO$ZU1eW6Dfv9y z!<-^6BP<;5V*b!6MZ4G3p>6NnL4(e=t3$@KX5;6 zkdbC)vfSyTWOK~3A9zmhZ*=v{M!m(jwX+Vb<~z0s5XvMw8Fjl{d(;119R!Vq>-#(( zHl7W+bxqk|m;rY>-m~F|t{=P&hKh7@t|J(iO52x`HJ9yf*kAy}`Z^L0m+z{}ceUe$ zboA~99l5lwxx7vrN*y#1MG<$_DS=GV9uXZx!~h9=h}5dVra!DObZ>+h@7h|*P=hEg z1PjEQutITGW%4*sMQI%ZWg= z(BMYZbfrs%_M@18J7VfT-gM#JEASHpb%QuRl^TS^6OI+8=xCIH56WqZ}zhXqXsuQtL`2M@pdfMXFP~I*at>m31W~(|y+Za1AkqTui^B zuNpX_-tPw$%eDZH1)E}PK0&n;1vi!BEza!oL3{7A_oB+gQiW-)AM&=@;G#I6hm($IB)lrIMG8OvW^obEtPk?`VK1zs3okBc5mg;~qiuGWWTOOWeEonHc5B1K zx+kO~Y9!M&&-L30HgnOueXzNl+~C`quu-~Jqbb%txO!MC9SYw57C}giv*_X1R@5|+uxyAAuu72eXv4ojo=la1s24ov37o?F$ z4bJE3kxS+a%ZzysoAr7w$DKJZf8G0w zXZfxYBHsu=<5g^%MBdw5-m zynlT;=D(9`MuUX7hMq^vR*{UuUTyZ-^UfqF>r8D@gAH|jrbt_FdAd}Nmt@Iod?p!( z$u~r=5FmK9>6srJlR^CbV~DfVyePFMON~&GrY2qemw`$_oL4RsO4g22Av!yrt>k?> zONC-FH=GfQ5_a##@6&cNn}I}EG8@0oD*W?;JGj^X`h8*Vg4fus#CH4CeImX$?Ahzq zWQaU5aZ)LCv1$i4X0a7`-T^2dw8`XdXsD zvj??AFZd6-8oE4`PGdu1&d7%KFA&G!uU!y~L5ioj1C+!OD0gO7d@?&>ecCrxJe|sz zNQ!JtK zoV&s#!u>aX-|+#o&;M>KM|0dNy+0~E_?i20CUm}oOPb%ie`zZRf86?Gns1nNw%^1Q zX9qkNEu+3gI7gChK^BQRg4iUf8wEPS8bbkA7({W9$Js?r;6=woEMlVzvE0`q;{LH* zb~0qD$t-L(`ctWX-t}zm>FLvaBAvr&^P9|KCwcob$!=HE!}@SK6!j{IRiqn3DYE0p z3~Qg)hBNlzDW6wy`wV}N&P5ce*$6c6;hmVZMVRaxBI6w=L6__#Sq15!dB$+Zk}nme zcv8-Epd(zC$B-4^I0_7J8*e9JjjD;NVT_R#>6k&`I>+L}+u~!eB&uvw{7+b$&3$rV zBGs8Ttm9c2hmIKrKf7(1p8YnWf37cXz7wwCACrq4?}(AyA2}+;t)R6kq}hqma0J{P zbRdR>Or}Z@F2GA?f!{5Ztu>$TPl?; z714NpJ+~beOdDjLz{<&Ko?7X1$GJKdkn9^S;%-= zPbwqfKT>p5jX+J;MCv-da=kKn>Ej8Q0$Wy5uv z=}c%^XKX6c&XF8<+l%Qm=F`Od@qkIdIAG2rjh{5@*k=ZVWVh+ZT83FVO5s_)vMtTRo;lA(FrIXrg?M|j7H6TH)Iyb(TyYtLn~NQUYH4a02%is&4J zb!~Gy*Z+TOTiZ6P-7>P+THBQFbCzMOqFv-)0jI?xgpFtzP$AYT8%SX>$BJmhtoV<6 zPa#6@L$Sb8Ab^;QmB31%`PthRU%LDK_k#35X@!bWTG{lf#a-w$Y12^0eAvU>9)KK% zvoH`4P}X3}fKDzeQaX?oBCPDf)no#N0E;A*k5LHu9nj@eH7^9Qik9i?O~*Ec!x75N^>({Y0*uO&10$Teiev;pmVt`3}TMD-0sbsUHCyL?j%p##6n$ znMi-qh{X^1Ul$_!*DBMUoI#YX!Oq}NGnsLcFb2KwoXft=tC1C0`V}8@-!|5~g5hBebE)X4pfPy~+UQN9fEa;3w5^B`{fd0sujl z$K9wr8UYjWuLeS+dlv>Hk>T^|SM+7mo<&t=;Jx*p#3f;;yGd(=gbr1Kc> z65RBY1y3|jQAQoHb&)2uEeucN0Em{9iHL@U;Gxj0VNNgOR|O^RvYNkO z+TUZ1TGn7JOWY9sIMh1E0rB-%?!w-vpkfF%U;)9TQK}yuh&DSttRO`L2ZfxOCHK#S zR-2l2GPa37r~Ivtn3et^&UiS~@9FdQcq5wMP~Fjp?&;g+%lBmm^eyHc5}&_Y=;}?# z5R_Fy8GS0x7V)7J6w)eE(p zc~Y{7@5@gvTwOlyPpO;SF!E30&YGDq5{T!bUS)>1@6wcaFuO?gYReIzyL9|(;QhdU zDF~0}SO$1ww)9Wg=1l7e zjuGl4diCpGbvMkxRj*zMTv+AhGq|}@nH|g`2p_`nRWA$_+V|u__rCYukZ`dWWGN^g z4VE`~KZQl9nv?yN%|gtT`zK8#+l6V{r@WiW&RzR2xn#d^q?q=6$NHog4Db}`GM-0k zY@qHC9z^H$qOrD83);<2bxqHu9T6E$$PloQHLlnazwW>c55i_58K%uTIaG%%$dSzg zyD$ajXjG*v=+OtSN8**cu005K_Y2uw%$|kK@1p$r2fv8&gV)~79$^?n#Fuy7f_(70 zOUl|@gOaM-qR5v!qCv61MBq(KB{sjnyYKLp%ZYX*5)tp~IrDkfBk*Un-)FC!ZHla} zLj8wN%y&O4^z|RW`-neUwRLz&Xt7|!#B5m$R?oC#%Sq^7A*NvEB5W`@ws2QTt#fds z1@xDZ&-mu&shV%wMIPVlaq@xNaq`0ZJiczDS}D!c)aTu-YMz46bI~@RroQUW<;i_7 z^sRvzFiUK~3|?}i@=Q3{#IHw239F~a348x)EvsFp1(5Y4prOs6g)k9G21Y(<#LAKV zniM$@P=(5p6{~TjP7?{Z2!bGaL=q>`bT82<31syL;uEOlnm%$e zEkGFE)^Z~C!kmRayq97`G{hCa53o$JJq1CRNwNWp8`cvo5ei}HOJ&$5*law396EDu z_3#`_u%K%s;0eNw!#{0nK5Pbp>YPUux5>~@_mC~=ns2owGo-l#<4$OZNEfi|QkN$# zrFTMoMscF%4y*1tIE_4kl%V{?vR71j>m!Sx$-fV61;UU3jvOkY=U$^&K%E>rndWQZeZI4G8v(XP;C(c0;P@4 zom%tRkDT-hL*0DRPkI@Id*rU#Ra2Mgcc0I<> z-ED%1r<>MRl-Hp3v+olkoU_FW+(S)hLbxC1htZik679fC8-q=?R70 zQsz$iddGHF@PPWflolC{Y(xzvWNIfK%eMXi5kSdX z22&$Un{uR5isQtgUeFlC*de(BACdLn>$8W{Dt^^5mvYSxNDWZP%e7gemyQCJFA3Wm4W zj3y0l)+kSS(I!|xCqBsD8CHujW0!c#=gNuY_;Mv%C#!g@Y^FLe=Ade8!%o@yE zR`&euvlE8as~LT>1BVe{@0FUmL~fpvL7AW3SJ2Es-5bsK)KT{88v7GaY%MVi7=8|@ zIF0~DCXp@UzXWtt9@!f1xIU!{RwZMIW@3ppJWl+JMt9BaRo>tYy5Htjr&TSNK}kka$Eb5rmndNPi!p}M z5(+unWMzo$=UUn6XB?^+bCZXbJ984=gT%(x#0xady|@J_ablnmt_O~#9TGEJTNd4# zc8>0z%_vrI)~$;b%llU3n=59pIZBz8au$Ny<*c_N_T>!=#lj6lO4u-c8TA?>dUlp< zDDCP{50Ug-&M@BdxYDpfdXF2)5V-K8qwln=cWUS+BEzRFP~Xp_#_Vg(-`Q?$cVmh%YAUf82NZK0OF3G#nzvXbpBJH)-F^B6-aFH< zCbbmz9ahL!YU$Ef;>4*2=enQ%kW6JQWHRz&Bv%jK+$xo zcNt~2{iglSUX1xP$5LPCSs(4tTw?q&6EG<-@Eyg|Egy+BU$0w_SY3Zh8&#{>R zNzEtgh878!du`!q>+mAQjhFfT5Pi9o6`$`Ks0F~ctZyTHZ?VML(GY}*gs; zSz7W8q1WXS#_(f8w$-e14C++J`ua9Aa%j*+wAtRkF%*Yqc$>4wETUdih6QTwz(I#Y8b_|bt z{fem~i_74M>FpihKy(kS?}a5t8v`wjGhX$FeVfX@+K#9Kej}*Dxqo{p)_atL(;?)n zoa4@rJL^-nHHK(a+}7f=wFLnwpS2^(OmVbUwUlg4iUVh9R(7L4`1>=xnQS+3ZiA zy;aX_)C*eyuAG%jt(LrHgXOnm8Md1t?V1_sNRLSCjPa$gVs>;ysGu=#iA<(7O74J& z16!NyQy{zzn2WUOlkvHZaVM+tG@Mo=lsvs)e#uk zU5)}9NlZpu+fCTXY#952nH9AZG_Yn(4y17aZ(WxHVN$!)2Y1Xk@M!}uwC*9( z+gvMSxll$&b%L$&FOH%P(^;xb?FWtL%RG4v3tCdi$wgS7o9kD3{NG;m_}UNX@vy1I z^I@x71P(Vx^=~oQ_S@o-x^mZ(7d^+X=2fe_nVyfo`tvIet*8GcmBt&yh?L{tr$CuKn|cTu zq!uTaaEi;S(SiZAD3y)ksAXN-6$o25j098a@C{ZZ*mW(P3Wn_)hE@O6YY0`|D~izd zP0wcPr&qXBo{CsEe2wwuy%=7MaXaM9XD|jyFsfupjc#Uf6JjGO1zneDzXgecGHD~A z4_6<$1_?2`Pt72C5FG5(+_h8J-V;;8+FA%QjF~a-LGg=w5RD#I(UM_6h)Aq^bS|jZ zGW>oVQ%ok~JxfCIDMu6=Dc;B;8*XVoVU88IF;{`?9jx~z`;|r5uI#|-B_aYk32zDN zVGVFB-qZo&gC3`uig}8e1koHDB+SOB%OSr&sFDH9VPy-qU!^S(s=AxZc+Q&Y8;8~8 zc;A$ji<_I=YA6v8O%L;xzvFYm(};iZy{oq^eJm7<6gxK3ebwUg@ZTy{>9&m>#YimV z#y1S{6+Sn#Aui(EhG4VBb8^YU&>?4Bgf6l-C#ilbEqJ02+_b`nO(+kIX|tXT*pWXA zgK~2D`G0q_NrGr7qu0unw?-`+Gu zSfE>~DHJh%Agor!vu+@$ZFO?m<_DaZQtWHbmId7V)prt$agX}$gt@LqbH0>I9-G{A za?@B)&30}Z8rY(3&TFASmu5QeiP%30wj+mXcQ)G-=wkk&7<)@x3FfQA>rd{fUP);p z#yf|$RRPmVn*I*ZbjbXeY4DWAM4*I9M5z$(tzLi{3{tm5DKQD^1(L8K^xuhn@+~*c zI->||P~`&z|99Zad-<^QdR<-A)f?uWCv7~u-%^mJ)+_9AoN+R`!^uLyFMMqO?x60C zJ8T)x!j2QV8Op>5jvCv%8>H9-L{CZhAD@&*)C7RiSRs%Gl+rbZM19!$vBxThhKDD1 z)c#N1{7&Dt!GUTilE@eFADr7t)q%lnecu5WTG%lecAT*BY-%Evv7ol=E~ge2dfI0) zeVGXU5UgckA%(MsX3h8YAs&;rhP`>&!|_f@=#z_>LB}+hjj_#uMqrp*0VW1)Fr~t* zDp(o{V5g^H??M+gD=gkAqDI&X8&R-c*criU^odMrkBFF&F#gB+Jy0i;gIOz{_;*mX z>#93h%+i*ZEt=6u;b>+sh$JIoGWvcxOY=PdJA@_JA)Ey_MKK=RF!_iYFk;7`g(u@l zs~!pDK~klz_5Lsuy2z|RJZm8G#NAHDP@jQc^fM~9c%wFA05zYX|GJ&poYO64A|ObR zpChVrT!`aJ?N6~yKw)jU&o0t`4%|L@tZ{e;VzHIUP7}L6TZ(EF1`w2rB@oV~111hM zsoF!E0b;9T5y2lyxjh!E{e3$9&A1Xl_;M4@mZr$L5g6FSVj`VRTMxSPXXo7qyI_q` z(vBF$MmKFmBi0?dFa?uU#-g`~ zafwj^^usFDA4Xvxb%Y)?su<<$AO<~cIa))%TR((xA|7z8gigsVoW zNpftQ#6C%>PdQyGVl@@<7=D<*!|=Fv;E&o8u)O*|V4?VSCk_ksbM-=z)}(lfr{1*qAph((KAx5LoCP3u_|r}2 ze8a{vI8tld0tUmwKxOoE6v_kO+A5Rc>aP+35mbi-b z?X&oLZS9|Fu;%CKDC}#AWjJ=s#Jg44S1%z(@b9+8fJc_3#m^KZD7+5S2I>;I=T=f= zl!V|28XXHk`ZguYLi_7|S8uMAvist({n=7wb2gG#Gl0L34zGq*@;T7fKU#(%86Sh)><9(=Tx><@t3pKPITbUajIJ@_(K($?~}2QC{{LP5weqK z!zr*19^Q{M$jNkd5=pi7{~nE=ccbm+ld1EFulEy>ONtQpgr*(PGU1;TLZ@u|RH*Qi za7K&lRt_9cYCoNQ&o+7wt;-*Rzx-{yqpOV~9uH#|<#Z1$)IpdNiv zPxjKwdjlv{d-b&HeTu(NCvJ#DPMwNGZb+oBOWzO*-H``Th^?oYQ@anCiZmi_<-o|M+W%E_Vyn9NN?}JPVWCO0e=3Z%jE7I0m}%3#>#Wi$-@ zl`N;f4BN22aVr+bxJ-0UnX&PHGhpA2ZSr2T+l*;;$aW-O3ZZ0&YCVD^0` zW=?g_Azi5BAYRE_JZ81)7Vdn)3TfFxkuI!@k*%deIW>vj==z>;xWm#?n$@v(?Y1mJ zaow)#N;Z&GbH7pAIx<4&h;1`m(iK^QETS51Ho*%uEFeWJ0E^s$o#q;*q8O_|8X67* zLiA4NIguq|)Sw$g)4;3);y_QOIz^98c?)rermt5v6U`+_39JScV161{FU0et@C%rU z6joX>6u;2MACXkT3~1oTHb$LwrG?TuCtCXv#n(`??t+_Lr-U?H(*vbEw$%e}ES4_? zbesu+KhZKK0$*ohQ%p+n$arWfmKo`Yn;Fds5&Py$*o+!ez^^0BUP zPzxwvMLk;?mWf8enXpY3;?i8Awf|{jr{rbUApWy~OP-mfF9; zituYLLw35)`@U-A@9{NpALcWx5ja{MQtt4UC1WYGB7hF;3`4*=DUtXV@n*{j-(Lut z=A3B;3-^Z|Yqm4&m)Ch--(M-F2RnC638?xW1 zs&BMI?yGGGOpHJA9%N&ehBXMS42ecac$dnlBE`p&C9(LfC*CFh?Gj_VP{N0J(@ zut10VPEzUQ=skKRaKkk;ma{&qtm+Cx!D;o>oYpba|lK~uO$$R zrZ8c_DB&jZgd`1im1H6~5@B39hH_l%o%)iZ03daf&Uolxu+xefztvDHxk`uY0C-C& znwySwR2;+2c5jY$=M$C{IjED4C!_TYF6!xM-&8KFBr-?sj&M>Hp@tR;7{#Y!-NRAW zHY`Z=MYj^MHYWn{k{SLX;xI4hwU6nr)5CwVPZxM!-$1zJ#oDiU3BE+&0JqC{~Xd$iRRj889oBQo&k1xRUDOCV&#z(?Rz}?8Ib!rm#s*%N&a@=ORHCgI^E1;ZXbccxF_a6_k^GU57}T zLJBOAaMF>uV|*^!9^Lc_o&xV^R$JD-*MlDq7W@&B&ndhUqVK5&;wn)Av zRKIBQL+W7oHw~;M4D6Tuv=-!uo^G1mxAf-oy&Y%a=5rEmKYd~+cgaZ0+>Nk_-(St+ zhEsGCG?Ysxq3sa~XDC2Qz2foLMMl$2x;C#Y&O9?fn;5kQfnN4jwSDLf5-+5VMk?k1C-`K$!Gngn#qAEY6*Ym{6lXoVp zvN1}x8t$^p47g!C2a)Tb)sCQ%?Y5P%`)5{KbG6$}!zi#D;72!X1MQq~AngehASbaV>)*#IlvID8ll#9j$v$!cxoQ$hLLD+yGcoXJqQ~y6TSFkay z=ZTBQp=uYrIRi1h(cv}9oKdZKH@Q}&3d^yUzNUJOBcjw?bKNeQ#mF!=3CnDG1gj0M zg$(QfkdBOl$RQWOut0{$ut*?QrZnRA($Eo2q0Dqd^!;`;Py9O)Lh0*MVC(`mVXFeW zIwdd(ykT=N&&RLIQ@Gx^ZN=5`TvAa2qPrW6RkJG z2BcxDAHojWj@j4SzzTaRy|lY|?gG8Q`=+WmQLXf%fUll;{U;K+E&1q&*f*w_OUy*` zujMoU#AOd#%<`9yGd}%v(`9)p#s%uq3x5oI=P!^J0Qfr~Mk(F&0!w5CAU05JscicK z5K`kBJXM9gw{5=*b7qoILuTB(wz4m>Ri$%f8!g z+qdQVh#I4JA@0mq&}DzbJ}c5+l@a+(B0g2oRczT+@uN^y*{vk4x6W9_=K@Bs_LEy& zB_#epkA`crwxdq$582T}q6g^&UtFUVAeS)a`p1Yp`#2B-NF=cB2MZ))*l}D=az6Ai zAMm%3Or{X8jClJxr$?|m%>GXv-;UtxwiegNW~kPg%#)IRjW*tniB^#^T3AKOuT0Ct4aQEmv~q`)9~K>#X|TH7LnB1MXX@GMM8&Y()QGgMJnTJq;S!XKE- z`z$e^UItybm9b-PTNl5Jx~Sgj`iRx-D(d$D`=Zeu2bjq90H#szH@{mievUxRkgwnz zS9O-kvs{$f#$~y7jw>y{@IB}Gp7V5C76H|ubrCudX)2y=DmN8ZInC?Q$9H&);kw)l46}t_u1A=5{2Z9 zW%J!Aq;QJyl_d{zo}`(H2csi>8&|!g_adtn^UhM(OY2xoWP(2ir#B`H%cFvwNkz6R z%Jv9Aa|FEf3TZltuU#!hw*MH0C!F3NrVEG{zy+Mr`Il{2+w3<=v4H#-E?Zz91q*0w zykYje@_U7pN?Qa4b1O}ZSIDFfAby&ZeWcuDU0;frWs=~hRO(4`9PlE{ipM9~p+k+m z`O|NH^U<~aQ7D>o6Z;(~p`4LSX2^({Q6p*`p+nS+Zty;9A4B4HjCbEOe8a>kJR+e- z`_~?QGaeIzHo6_LKQbHXA(=rlhF{_@%Fl{r@fbWawupE|t~pKKi5PqMo*?(x3+yY> z1I~&l8MP0A_Is<4Y3M`KtN6f{hixuZ?FBS3MHh*9kIB?`C)JUUzeEIkF{J zF^=>UHg7KU95E^{6b>9HcrHvYq}Cq}OvO6~20G(Yfy3)l+a^l({#YUr+i#a9f>%#Z zUrn(`(EqCVbL>xRAwlnGI|K~j=C(Jsy`$}YZ69v?-L}txU*hP;*q#|Rm>ij`R`fLT zZq(0Aq-jvFGC>=Xue7Yb!rwOE-&}5fue$xQ$F^I>plSyK$82qGPP2~%0=7CBn3HJa zr9S-NBP-tHUtAWML>%8fI=bBo?Jz_+7!n)H<&7c~EDK`?40HatfuYK`w;WHea`oc# zm@UN5N;+x+r5lATvxVq2^s!72G3y~6`&j8Fu{qEK-Bz7^l2A-K2x8^2KH^Z%h_iFB zF9Xp&iFF8iGE!V3mjj{5)R>LnDsDG53PK5GfOug@}W zy#{S!o0r~7ks(?#T}t_qnhci@D|+wJ)R^)XRV~@}?X{U-9hh@S;NPX#wTv|LVvJl6BdMwJSqs zaMOt^N&&jX>0Ttj;60s;;5|9%wa8!*fQ`CSik5r(O5M4j9Y`sGV05x$^A(4-6sMz} ztn?=cXb>m`5gWmZj1;>DGeIR-4g`=v&rMvhY4fWxB*!-FJJu6gK(m)vu_2|`x4=Mc znA~CmOr`zMC(JL&INR)r1MeQ`O~ADkZMVGt@_k{-yA&EoteH5pZXhO{wT`P~0;-cM z-g1D1k*T#t44`As+;sWL2Om6??1@Fw-3jsdy*F$*3=b+Nnp!uy?P$u0I`@PkA~4o@ z`B6wE9cn;#%%s)_{-AcgzcZfdFUBR`M|O};B0fWzVxgu9D+VhuIngF9rf3HzMF8n? z*ve%5Z3}OUx38Tp7R95}`TZgAYv6c(OMZm*=kK8F#p3kZb}=_SOE)BN2X5dG+`#jP zVrqleUj%Ie#jmDEWCvCz3jy{sq7H?~DY=P>&Abm=m|8itdrw zAf9LLH=D?x;s5f9^KdH7C{0xCZ4@)E?m-2fo>;Ib?0P%V8tejCr`UyR8DcWTWYAWwsx2G~ zgn~zAhzX&ssrF|}!GQYJQxB>_eFKdj>D2yE>B77*kogod2OI;r76Pgv8H+YcWXFKc zfldOWCJ-U1M086yn{4}IKQ{FxOTTiV_IJOb3T@-wxif*lo37IBv$p=K^_Kn7t-7_O zo7%!7zoCYMgF@UtclG-m*k2Ic@!FJF=7;WC1CY8~5~I4MA11FicTo>7{7nr8Y)os! z8Tf?R#W^Hl*fYc~sN1QyrwF`i#-m132CSW@*f)0p*D6AJa`_NDs_sDP@*<~#B|Zjq zBVNKrpFjtTib#PIy&=KaN(t+oHZn+TVqtcEes+-#3v3Md&lIeEbaYfKz@`NMewCfo zFSOxe^&tQnvhDu!Tb}OwzBfHn{@Q<5ZINFAT{iv$Pp|-lWCLdXY}-v-Pb@V+CD=b; ztVf<=b&5(Jg1io>Q^#9k2~DZ`cm?KAbPH@~SR91fNyoGTcIcxmARm=s_z@O_A}03= zv3GJ}Z-4lx9-6RsteM=q4{||a6gZ)MOgG;U9;aPmCZr!o)xMV*3|^k;GY`40vDPpV zTI-vaFJ!v47fWNqU6!>r5CEJX3am{Cu|~A*pPalL51*K>WPa5inA$rvvDX+um?#%~ zVFrJ2|9Go$c_npME|qomC@{R6ySuMAGN+7`x`vhAc#w$)?#gs_f>H9YcBW5c@xPC> z#@@8@>|z`Ia=o-1pr9V}^Ep?cCB@EIXF+^*q70pe5N{f*5EuCvii68TNY;0L^_{hM zv8r$cxuU>0U!bFi@F~ju3G<6j*x@2wD#|06Yd$?v8X78%)ShLX;Wb|BoP|()o=;%? zQX@Uu(97~cE5ADqnHj?_EsLN+gM^PIjFq(7`R3ePpX%H+Q_zA@C6K*h>y}sLf`LTv z%Z1G|ooX;9=-kW|?STNSg%`jIFR{09F_B+tPxq9PnV?n(1hRpNq2Vbt9}Ko@>MG}H zJ$QH&$HHZ0j0Z#c#DL%<;yxzhvUyzgEXrAV&j7~@UFmtqBf9BYk512W>2ckhoqm*N z4^GdgVe%+4>8{#Ynx5ie%f&@5j)156!SpN{M$z{rFzJxud98vzZo~dELUX653zmZb zAiPZu>c=t_vE)!h$^?&VA|*TDr}bj<0hu6+CafzoBgGm`5N=E(fEF~D#J|>lsELs6 zDq68ClPU@k`^#uKlSGQQ!p@yEgH9SD#09(`knC{qPE-}E0L?+0>cxd(%+7|(>8@fb z8;xW#$kEZ6EXBkzhBs|vZOlF&f0%#?y^nv?RFqSwt=`Vde#v&CncPZ{4bbUV*sOa7 zfvgPHW1hUM_AG34oQ-~<6D;ftFF=>|6!tP89dULiCWeRhjGsT{x=$fcg8UGVKNE>O zV;FptW4s6(zC}<{YCCyIW63ilCz)P`>xd!7h{3!N%qf%720f+w33Kj&?JJn8N%5S! zX$_6s@v1#iSSXCx)#LPa&F0Wf$A}KZLs<1^kOd$VABY-G?f$z|OTBAv39*hK8*a#T zb>%ilV7jz3R94*wLU9P3kTc_<2VAur`t0$FqEwK&Lh2G1VPibjHU&FfNW9V}f?+?Z zGSwFz;*%s)P>y;zI#AD&ya8SgZ4Ha+FtVoMV*;T|0YoN8;7fu$beN@R4?giB7COo@ z^-WJ98?=n|v6-E2yPmT0uA8?~db_)GCbq$_0?!mPW4|&mmbqM8Gch(KRMP;%ozN6S zeC$YR#sjfUWtj!1dvk1~;dXX~;)&2`$y8NW*IiXLOQU2V-qq*b8_Rug7utRsQ}<*_V|S)1czCv!;B631uTBAOWzxY2O1gC$ zWKoC59Yz*YYU_U3L&n0Sbnv!8&`~^45fgjGK5S8ILwSVa4qJz@7fxYw+&z|xJCWiv zjoI{$&PNj4Xywhida9z^Yw7G2#boW*<*umrt4L-wES|$2W*DR1ehA*Q)Hx{H_D)Rg z!+y8{W>?#~t?vjjII8;j_GHG{E3h%{D~&0KRJixx ziC3hHr4dB|i_Uw7$FsMC;{&E7vpX?=0KI)nmZYf)BbH!ueAmrxx4;vtSHwgkJUk7Hf;qqgaBcKsNyQU<3uR$Uo*2Z6#klx6B` z_5?v%?bK_(q=nTlU0YZeOa%X|JJ|K6JPM=h)nu^yrmkRjpmICu&2O*#*%}zlJ+b@k zh$$QDxw)@un%BS6w2t=O+ykv)zN5Dn{U-ORqBz^uMfCuISk3v}3tQ^*ilVq>v>O2o zz=hMt=$6BGA6}9!1Cj9qHo2C2mnZB4;~NhjZueaXB(Dhl`~q?k6lr!7T>?i;yN-{Q z6F2NfMiqBDVKsUvg*P1DI8dIAA{54#WO&L2=OD;ybhbQzH{5+VbttNaYl~bBj|Pfj zod#aIZO~nkEP!2%$OxIMP?-e>J+*49$Af{23T%_%+ zC!(f#3>*cepbs9nmHKoEeR`1MOt*~PrRqk!JMUZgB!+^z7Cuj2l-@K5a%;{1N)Gw5 znx`TO)5x10NPoCJ{-sG#I?7 zhgZ}3pcY?x7D4p(=Ze}0+W`7Z=rFf?l5F9jnqu7!Or)^?(!{^Sdg^LgXPvpEorY8} z+*L~3un6CHc#F8ydizoHLevwv&I}eurkL$Kym91&^`na`d#C{w-h- z(IA*Ous>&cq%?}=$&zB;OgeL&DXRSp zjm$;#^8xCgZA{b97Lt)=Y)8q|VYHYvtdAPFI7tk^C}Q7eB?_vJ`HnbFCL)cyF@JDk zVO|n$w3)o3r)iPlW?FV`j7MYR#>1(YYvAWNL&M?`T)CHYiEp;8A!HaHr$o*{NE-$L zRN^YJ52v^1LTXL5QXqe=h1Og=0mxx5uh#NYU_Uj{lWSWRG zmLYOzcKB~4>X-?uzu>w&;rr}{tC|v zDE#7bqjaKwOJ?Pm;}RjLPN46}pPXn9}xS-#eEn=FnnE_Um|>Hy&o1J#2Of`>iY_FmML zcwK;Rh@Ax%9*f`GV&xL!y;Z+t9S)%)S*6{?GQ zkBLgWo=#XgVl1;|NWFKdV|^2v1~7u9I5qxVZ}ua3F4?Wr+W3PHO9-jK6Z+7$p+yD_ z7&;JhR6c(OAP4Q+i>%9|9Pl6nY&8N|hX!d2p)MZMqA%0E`KGC2@xa8EU_h&#*8;&U z69*2CE{+`lXxGRP@nIN*40VaQ3vIpY#san-7+cpXP+s@hc@l=`Fhxcq61SS%5B8_qr9O3*km4 z1j3Qpg1+_q19k8WJwz{%bQ5M@Y|GRj>e5VyAqL9IsT6BPu~aNVwO~-iAP&{q7+^bJ zn-j&wnHfNC5rz9p=b?WyYTxYC4T_rD;zb?t{nnxFSI(FP1`2Odrk7Qv&~n20!g_H$5g zie6T>@_Zv(2#?*K@T7c1K@n*Rg!8y7 z$&R*+O0cISqQ#ObthRsyxh&#C$TChnN@2dljB^<2D!3#aAidjIVdX9zG6Ni7kbJNZkHN1YShf{v=*u>^R&6RbwG6sR-&~uh7wAL1c{ADs65t|_ z?F7W0MtPCX)Ry=RZ0ZqJ161M^e_h~fi%mCkapr8}%x!W_uU%9=Y2Rz*ah#54azDi+am?M1hNl}EVRVI_A;`uIYR@g590WLCrn*>TS1WPH5 z3ZhuxJtNp037sIEg6u4M^>Ji3TIs@gZ+tmt67>UY0^Gn8>ckrP&H&5Zvezo);-QV9 zM6SbH*nLJk-g@e(GrIxI`KA)*Dh5YsIFmh)#hz>G?=&h%3On3|VDTF#D!E+WF6f0$Z+&!x5i?(UdF}c-)l1)jvRrzjmLA<>q1tj zFTXZJ*vj?z)MPo4zGR)$(AdVY&=dA~`(@N@p?l4mZqZgKl?qfpTE7xAl702kog{gC z7jQT#CQhx=PqXN?zd5?;yjZwIH*vR}IkEvgMYP|q2TM6GF;t#}amn|;^t41&}y)Fi~{qSbf!_5m=fQO=aEo{BSuB$iyaAkU49Vr z9f|uAu{&e&08J}B>E6&WxGj={SZ*EZFmo`)iil(|&K#NChnG@5o3| z9LTx99WHKi^<6r4iuRtwxETTRj-(Z}7t{GWy>}74eiA59n~?TicprCdC(jDm%g`W= zBa$Zk(VD)Kt1Vy@ij|ufO^{CyeFs`6E^Ifad)_|Pds`?Sjo6!<0VR#Nw*h!28sj1z zb(6tq#W0jgAmzekH5`EHqSApc(X<$@{iM9M=O!l_P221G`_8CZG#v@ORYQR4Z>nk( z&-rV^c-)OCYJ22&g`vF#KZ-(pfL;u-{-jK^7W=^@VZ%LS#N0BCz+m|ky5*Bjt8n0; zXtMN&eX)oNoliIm-gNRtjz$lE7upQtIR868rv^{E(a1SDsYHUNz*Bv6?90%R4%f}m zf9PV4ON?65+(IG(UWa}Kp=0colbt4cYxTr?OKGmrvuS5WSDntidZee@*x_O(@r-&V z9i8QhB<4dMX7&%`-Qh^=^KvH2Ss6tiN&fa7Vh+2ew0)Cdkv+8zv4u3z#zl&k-elRE z+QUkjWN4ar)i?qFUUXlS(n5t(q^=&(Rali!bYbOA5t_1Lt7z+mfH`I=oiXG!#Mdbx z{~2L#H3NeYSOg6Q%&oS7RH_cD`g6ozK8K=H`}2@}-&84aMAwf@6*Bix%#%Lbc>82A z`F6vmy{2JfA0`|79x!8M`8>tA&eT{Oh%1L)sY{5^yG!3x(UC7e+x0`Xw1;(4<(6G$ zIBcdZH@8N2Gp_qw$F$SSq04J$4=S0lD_!duB4N8<6(Wa2-ba}4D3iYzD3uI30v?aX zvaW$8Zy=B7;e5)oL=m1n*Tft=OJ|fwt&LfoMZ_QCtzB;O2tqznsx)jKe2h*$0+ zmJ_4UK;$qE#Pm}g)UM{dKPs5y)-W_)KoKlw@rC87ero#~iGX%H8^Ju2x}85@5Z{vd zryHS>>uG@=XxxVq`l+?fz+hfcotuoYFI`8}?r~VTU8-t}BnTjy_3}Es(tczM8tIJ7 zKF_REmo_$+u44$9{qlx6GQt=!KAOH=vgT)3`i1vkh8WVm!Zbc$Ub5bEz|C(NhC0_Y4jab%0i~j9-ky4v(OuXRHaRI_?qc4ur=s|w z0~L2|gjU?V<^HOZ6?wyTWd?F7Ykc!KPHbSNU;>xq5S`0CgM8xv#e znwpOmqN$)0OywE!MKZU_RHpA;fxvHescM(_YsP+!%v1&1J&EJMmYecxF2cK@n>CF@ z0vjkMo++!2JupCG5|hVxoe*FjMaQxgs`z)u8AKY%w^PF& zzUjj=;#S*U1A`_omn=?1%-Xv7dC(4#=9A_cx?U`z6KI|NSeX&O1zS#s@PsIAeUY97 zA*8S^#<`=z!1f;a6~kRz{FLD)9uSwOjOp6KmVjl9hy`R;7hglM_6eL73)HVPR(}AV zfZ{hbC>-l0>og5sPnF!6w`!p|Du_iSZePk(u7!vHVSFv-DqOb)ohFpAZv74Zl318J zbzY9&NvrQ?*nUeIaW#sbq4#5FiIlb;sO}BhZXLLN{f*$FTHff{l^<_B;-cp^>})7b z6KVfQ|KD(OmRmIWyyMv=&ByKdMdh`6|b+(aK@2>~4dM`XW3}MZNrJaF$eMDIOU^~Kl zanN7#jwjNuNejzbFx5WmsAZ~G@PWQvbn5d93xq>Zo{4W_t;pO^ofOx_NAc0|tQSgo z8Yh2_#L-$r+F@o&Onvm~ZQHKib~R(j?`$fcD#CcS_Mq;RT=*u=>r1$6+tt@H*!<0= zviO~x1B#h*5HlY*dSlH=&DAcf3a=i1;w!M0l2h`|r#B5O{({HYQo7tmB+0OYd3dv+4c?IJC*!u2$yLB6}eiqlR)^X&TYX=)={Sb zzgzAm9juSOMFhVS(9h$?-+08yBJSRCu@S$@b$`Q1X!z+n5H~>d-u6YuF*BiGe;@&3 z?%F2~!b_Be70}m(HVs*y3(y5$21GGk256N6Tuaa*sJG-L88rh4fFymGE?6QdY&rF~ zfYny2_SK5L+gm^ zUNLP_Tvs1eb*QdM)_NZE{vG05po`C(ukPGIyKL6wA`zut78=16iuRyvlNFakX-gF2=>77jZ)Bovr^7KLEg|>(+$_n ztR31yULr~{=pf%6TvLQ_f<s6pdF6H&S_PvG}iqqQrP7HeoF@3Ip<#hUr6Z6)+Ad@o~f+HTuxWRC{{MS|<{5QiXJTYUd3* zaphIqL9gW_GCyY`;?Rr@bjm(-lc;^TG+X*A{eU)R`ox0$#K}EfW7({|b_;GUq_REn zN_K4L&M~^Y=RR)nYx(#WbOydy+h3OoB4%c={eh!_FXDmJAHfU%cHvvfd&Cn!CAPG^ zzU}ScBj9^M%2wQsS3Ug;=+5OWC)#69e>54fECtHvuZ(@=1c7aZKl zJtv(i*`$LTNT;eGnO!Cu;fxoawmx+iet{a}r|AEG$9z-~vxMg3Wn}I2%gsf$j*(n_8odmfM(8AX{FgKL z#8*x87$hjeI0n7lw_zOg!JrkO591Zhx{nxm49QlRu49BCi4WJ!eq3I9x$3LcEfzy)((m@$|*m@u8#Xn=2W3;+fob^m<*hI)bd&g843 zq0MgtXlK9u5geUI-s;3e#Q9{W6?8M^y>8foC)_UE)yT9#E?@V>#DII*g5;~;Cdqg-vA`U zx)GsfpHl+se?J=keBVu(OykmS>)2O%$Kmmk4<+!^Y%I44c2i7yU&Ow&0Ewjtn=aau zj)C4_7fyl~sv>$GxQHZC4A}jHmZZliM9t)SWL_k8Rh1e#pL117vY;)S_iZYa@uLm& z6?AEf5dSq~BGckSIR{Bj)Y%Xm#zWVep>NE=;HaNdG{3kw7Y}jbWmEi)g|Row- z>BV;Nz+lA*T9Kp{y<(|q?oDh&K;c9?jFmH~oMf$0s7ZAM~Yie1&Kv`Jnn)_-r_O&dnq&EMM}+lH8RyZrA+K^HWY zK=)21-GK5db>DWFhPJ2`~1<9ayIz-x*HNw`F?>k-28`D z0d|y7W5RiQq5>gxq&Kg|2jXhJH)MXn42dEs#p!Rv=Otxo@FFMM{o{BB-V3En+^~Vm z(!RfhwM4m-(m*PS)*hs}*Mjzpr+ldcI}wr;w$u*ND%e~!0bDMm+ZD^QQ)@-4&?75V zddWCrEc@3Q7=7-d%5S3R}?TVB!C{^JiLXs1@SZBfJL6P4* zF|m8%bDv-5P80*dWM48EC{DN@z%YtS6T53a+TPwJbX|18xi?62mE{`p^<|5-<$cBk z27N+C(b^^YPcY6b8F)7K;zMesUSim}E+G`<5+$6XGSND$;BftMHL-(s^2lyM92pu} zHp`eA57aYN0HwIZ*vT>V=cg_+Zeknq=K+oFi;I|I$_!>FhFHuk8%ccq5I5&Die)@u zK+Y^B4a`_0S)xIX-~qLHF;Y8^>G2;;<0;2hw(k>q$T8ws!^rZUxd_{tMfkFh(GCz# z5_?)kTsm;5n2umq1H?29X+C=5LV!$-dmMzk12O!UihC!;3f7vkau>zipZ1KveRguB`%mE)Q#&=>r8%aJ(EO(O8=BUCBS6?R+qCuK z$Sg&k?}`0A8HD~lgdg(9-oYjG&i6RHYuytfB65~`rb_Sz4`w?_2r1d{l8>=b?kP?+u=)|uVfzdOzn6X zli<}XDkfjS;A6hRD3|r=A752gK8YwYZ)*DlYeXe7<;%hzmIHZ_R$>D3qP*^(=d-x# z-|t`dK7FZiwNH8`JsUWL$ISyir-e@hy^N@dO+LwBp}_S)oPx%)pLrX3tX@?9-}sDV zQ;J-^`J#K$d`A9OmiO?PN-+4TpbIrcP%FAtG!qP_I#^4v=f<*?Yac3VxVm7{y*Kl* z%E$hT%JRjs66uTXk!R#@WqA+Z^MB##p9I5Yx9u_zUx7uuvM zRGep}X_1bR{&k7?+A>&8#4);Ae&pe28gphwMh5f!d=INk`t!ffPLh21O1?jlNaR)g zDukc$M|n*GH0vuwPv!o*h&{l@-gbceX;p0LH`|jC15s^ce{vZgGK=(2tyOvR()Xc428IA<*Yaf1U=8w_W}2T=f$!)Fy8!>xqN~- zIUMMB%=5DDiQ@T^#)VfPvx?Y^$u3b}294^HO&ljSePp-`MucQ1xPWRdQDy1NcIT(g ze;^SQPMq%=9>M7kHZJBz5cHqk&3(Z3kYUV&i~2zHuC4-ZPS+y_wrxY8Wy9RM)x?L< z@~ySIYJa=ZJ>Di5>zbm@7 ztNXyDq{VR=VInRoId!0W7XXnU9+^w%q4tmw*SVs)67T7WEfq3PRrBO zN$+WR!lj;uaSkF*@1mH;IO{C0z+l+N%%@85XJG|mhk*1}A88B|MG}_sCOZF351l;u z(8+&&2*I+?!$0If`T51TZuLX=qi(RoJg%R7=8mXzHSm@Qc<>uK6l{O`-Y;&RVK70Vp@5fV;XgTy^NW)}`3 zi-~F`jAT~pb4wj&&`KD6Lzzlx-KZI~6UN5vgD?I!=lOztEx!QrN*P0>hxzqkm&+8j5`Z%5qD zj$>}wdA)FyQ;~4V3fwI0fY@V~-D4e{ZsgmhF>a=fFLv$i%x|{J?i-N1{OYh(3J2b( zIM{En=QzMW{{^;;5R=GyyCB>^FC$mr@Wdz0j2Vjric{CD|DcgGM`2EZ6z8$6`}S=t z=$|zeYsAdbka_Jf#kUbx5^)zVYRzULmiiJS-N$Y85g6dox8pHaeejZ-fowp*!p z>c1lkQfWMX&`Dc|z!Y5Pq|Jju71uZ!b22lwJ`@gp#IlNJyS4FyKd6*V`&ZW8W#%pM z*pHp4yM8>r&dk`?;{JnX+P)48^N^KxF3*gY!_n{utah_#S-U>Fl#JHOR^EK`w)1-aM=1M2dRBdgd^`) z6nobO5s4V@QIz*bYTqj#-M6hGErud+Kj8ZMn9Z)nx<_OWtSrn&x%$~`3rrk|^k@m# zPLAP+g)b=rq)$W-){ zim;8UZa8XKb%nI-S?z*9Z}u_Ztabqn6l|;T zuj0#O=L{AGunT}RtOM3lRi`Rg4&GD)ZiH=eO0?bd(1RyW?6OSoRU=z=UMIwjJGPAs zfO@^hatn6%F7EId1K0I|>v!yW4dROp*ag>m4`@|4fMDkJl^N^}qwqW0(uQz4jADU- zcd-F`W}M)4>5VYmLQqJGT+3z2es%~I%st#hR2awVFpZe13@ZTD6&D*iC>v#(!e&Cj zVsG5#0uUc*?{R8BaC+KDf^N6F^CmbO!ZaHeG_{4nl1+7)QS8#-+o8~&%EH4xTSc0~ zUK44RMn_>wYf=o^>9NB`rd!u{7jl$1F<02FSe1-%cnr~I@FD~WnvD$F{c9tUwf**> zt%ij$3O$4&!fK_eo55^0XzH-S1Z_jSBxN_+55QI+RWJ!=)%X#OLTg*!zY=q;RA&vmFvm4a9)`f=U~qTD;?J6 zVfBM5V9sHgrvn(bRe1cWGT_1pD!-J+(JK@UV=KbntjOvAAd^}2|J9zZ%L1+U(>GMi zm!&C2`Zz{~^l{h`WYy5GUhU$SsGaz^>z)qB&&1vP{X+B-wG#ua_u@yjuU@i}4ZAvy z*TxY8rL7j}m2;QR#TCwdZt zjJ~CK3aBu#-AW4lptl7OZY|EiMCr!82=H<@tU#yNw6o1Fwuf`<&Imoo8N`M^y8n$% z31$2%nceMd&O1z=T(#|K3tPjt*V78HQ& zL-N3@Ct=J}u3HZxy_~uAp@ie!k_{B2?|N6X7|7n@I*Eri2Vmf)kKjeY(NY?Y2;#rQ zV((5V75C&r@r<>7yO~Zrc+#x|z{~~bb$vdlK7+f`B~*ddF~^JTrCA1mf?$hYP%}0$ zps_=h2*Us&CJ+qcHzp$Iip8C9-Hvu!wi1ZhXYH5*Z|-umr#i8_*ztKiA-?>3xu+d-qPs zfvVexCdG4)+J)0Z>P{EzNA=oM&S45Ebr?w}3E@5LeD+aHj5*huv)nmMqeu0`r=01& z#lC5bQarR6ic6Cn^uhCxsTZJWK|Q+sX~FahJs>9u&*fg02T`5oYNiE49P*W1g=4zj zRI~ca`sB;M4GXf?iqU$HT=**T?JgpR3LzZC`Xf*pprQcS6RdW^$3T_*_*R@Xrz-x^ zjjY+ZyE_&e*gw-7aTkq*@gU-uLh}01P8?FIip-DVHZ>DIaQ~|}x`q+G?8G~Fg){1G zF!*&uM^))Q+!mtyBt2}_Z`EZa-%3&PJU?b-i^s?O9;=7ww{Q{WJO|$1-Ms$6@%L6J zW+>UJU|79i$)fFsUG=3tFtTmOjY3?va|>p2pK0wnaq_{3#N&;{J+MZI*X-JH{eX_k z#tiDh^OrggV+TyBAG6grjw6&-z)8N27)aDdB0&7Rx_+%K2?rqQ-6~b5&9_F#R?dUB zxQ%U|FF4-UgME@FEBzKUL{ny8T%wzmB96rd_JQ9KhUN2ZY2x_7VKm#jkNXi{tJ2nq zG>GUYCZxP3k+zOCpemG+~#&fM-bmk(-Y8H{H-7VQW}W6=cCOD*Hb-hi9i zE2kjwRB{6wVQ7v#7x8G^wGmv|Da3=0_ag|;J7suvZGcmm8sPE$f*1X*GR z1MS6PB4b@hqkQyLp)x5PjXF|n`=MICkt+`rYG(=q$EgTcObfJBA%zF?|c7>IY|D#Kmbq%z$Xj#hFV@qt45 z)ogfmH%!ih$zpMizhQd3#2!y*;cEUcE9~)>b2a+*YRCI%-qyd`IE@m<{pIcB@TyzA z1}p62z22+6j}^A=rEg~3kx*KBk);1=YJ76Ra;~;r8XV?Q=J}Kjm`m z-`?!Rl+@J;>k$io-Ed5r5o1H9`8osl;kG^)S)p9}x0Did-dyzVJ2f%`w;`&JQm>DW z9%~b6`DWHm0P}^qr@T=6hc`G85fVRh-E|A=@OJSUoI{RN=MADCU+JXV^ep0)VKLBn zhEBo(cD*VHe6I&0Vs{1KCZG3}Bd_|SL;Q|I52tV9VAgN4yTo1ov-=L?I=}DG!>OAP zkH&WH!ld)}bmARR^Oadgam#6+D+H%^zY9K5e4amuUF1UewA-?F5CMAy{+MhsFl#`a z##dM?Da0Co!Eu_`$b~kn9{g)r8)fPjJYyvux9JTltc7;@h85O8?eDx7VGIy&`NH3} z{Q~nJY(E9A#EXc_2ahkqr$0Ib2H_}n=A&R4hVa9i3iyg|1%b%mg#Wbdhul5Rzh}UW zfn3zNi`bI#dzx>5c=^L67OK8K# zsSa^)Ad)R0;3WzMwlsNs`L9H-`Js43ll@Sdqc$zq!;t?V6rhyhnPfM#3|l`88o1ny z4rH!;+m-XoM5c>K$qllwNGlwN-CM4_a?9>VN65{U4qx6)M0+=6M#coU!miE+k3$|T zufxDVsj6d}Lw^w3PKcKLM^VtySRFbR8^JPDo@Iz+f~1uQmEmCuP@s|ih8YdQX?#qV zz$aNwf_W8lg;FZnqlENc^8<$tbsQ?R&$buvd1zoAfj1+c8bKsVXWhG%fO>@*Q2GP9 z^6jRwZ~{?t&rwErHjY6RYS?a)zjYt)#;0bbLbfmtE$tPAj9h7bhDIlhczIEu^J($6x^4U{x% z27fe%tM-{t^aM5I zL^O04(t8wiqdGHFHT4dBX|Cs!z$Z{of4>90TA@Cqgz5D))GcevH#@RUv{Xtrs8T1a zzqgF-y0s(J)R2R#R7<@pG^)dVx!ww*OAX$HXmy?bp^Ip=o~R=WenC832N4!CG#21T zXT%)hP~OV30mBS};QJnv07on;)-@Rl5{V@cp?=OU`Bxg52**7Pto3eA0gsB|L1IKV z4K*HD4O0gigW>|phWeV7PYay)TR)md(J|0pNJp|U)lh>SdZ4_0Xj5fqNWFjMj#;I@&@n}dU~hGfcjfbLB$m?= zV&4n~*G&!AI%5GEV*mGTJl2t30RxYzUr_&a> z419E2^2%7speg9Ym9o|DUG6-+Ql9495v2F#AvWxPl_{O3r9J;UfFVV(gKh@(C2d5L z^#f8Fw}1pXBL#2|gAxXHLEsSbtE%U~UgC&NwQ~fbJk<>xVC`BQU`c~dwux!}eha5? z=D<&n(+}WW=svgdtz=chKm9e%;q3V#P`&;B0|nuH?Jt>yd(ulC4Sg$uOL$78+fR%jn25@Ew`Tzp1d;v%H$yHXzS_f8UM+Wc zf4hRs1#(O++wa&bxVK4vBzH1WpQoz~Kl)6g+$CY!fyInfP1$ebAwZ2`Yuj7ivJIKj ztka>mYlWe?RNnFy1xn1Y<-YVCr+=wmwLX`1(SHkB;^U-!l^)$DpX zGe(6QLL30VW!EU{k5cGLgmr)tYv6auCdKvdL|CaD1+MI#UMZkrPk;j6! zpP(MnIH}Y#;SB@Ej*!*KG)Yv%rr=f*ND!;d8&=#%tY*@+?ghvTU{%aH_gXFrhn){P z-r3q;)zAIoMHfF^zgEAWs@Tj|cW`{pWqdVb^29(hCSMP;-^5LyAvNxZW9aHQ!*Z?Q z+nlbQw(ZlJUi&$taNI5Gg&&Hqh~I@x0We?8G*at;5v`j9X{D-fG0}8h|BJ8ddJ3ZI zM?Qi>O4q-xCn!nnva^M6;2g5=KJtjBrBVEb_AO20bDH+ZBRVc62!n6G@H51E{)nuD zd_PI1b<3y9MB1-c)A`=grJnv_S-_dWWPE5_oStpB6v9R3*G7 ze)i>6;^Of@<`|OD@y>dMrp6$Rg08imuRiy(ULih8wZ(9K_)-IcdaST!dL+~2O@FKo zKy{7vfz#L@yw$^N29wuMlS_-9*P3|VUko*H&ffarl|xGchBAHL0s>FqT?L;mZ=k%T zwA}cLe2k>Mi&)zfFCMxqf~mnxvg(MVSCK0!EI@FQ;No-zxPrK|dSuUdwTBc(D(j6@ z+sbBwJ=O6&N2=*P@7@EWmdmHd_w8o6qc<;Lo>!6HT8%7#e2!6D6Zdh6D-`| zi1J=2%H|vI%-8<%w7hWRjq=9RU|*KMS0yCANn?RX)(y^1RsbQ<&s_Sk&`wY*B@(bg zweZ^I?~S94&1?w5570J`FbZ`Zb82Fw@zlH+x~Mg+H1XxVx4Eq}%JKT^>J6`yUZOdX zOIM59+K7=s4mF;~0k%}1!}2n5|F?S$<6fj>_!mR-Qf~i<&xFIvpW#Ol$36^_Av1iP zfzfcVzn0rGk!KijC#|Mk~B_i~}2OLx$tk z8i8e?HmDDXkLLORfw#q|_)}K8WiEhjk0kOA;=1|N$U#K6zhEx^V6$*wh&9W?xvi;3 zb9;*AhTrl;1m?RJnfCX>LkBhkh!FrQMOX`HLkc_~NmIC4NpHp!Nlz6NJ*Bi2v$okc zl{ME+uQjt%eWsl)@~LWuPi3lf>PNUJODkNgpOUo6e?PFjms10smQX!dTdMjpYxH`< z$mzy)1NbnEeY)}TY6!_4w_i6%ANqcs$hg!YK*u!>JA*d#kjF@XNhkCgl_T~K63e)F zvk%+DF!v}cA)XhhX8lA9(9h3?e&cJ<4LnKDM!*=!xDdxrc#Fr@fn`WOz3O9j*56ex zwC(}0k7}j?wC9Df$SPnwK$+^hfxjD7O8#_{#QE;s92jc1p44q!zYo07eL70XmL2f- z_B}?(!ejgBQbK>Xj(-Uhsg2G1FY2;<9~=fL@=P}BJFyRbOii?Gh^xJ&WmjCSzRT;e zd|&Krzy|f5%I6HUMDqr@S~XgC$(LJ?<@@9o!lH=UZ)#va{*Ij*Yu+$ddqeZCxmsQ8 z->1j*sIG%8(uq^T$2Qjj6I$dW zA4Eo8IIZg*cd_-mG-JNY^8+&!oT01-^#^TH#Iun2h&T@3s_?`t`d){`U&b3UTt|sO ztyWWSo;KHm)(pMhuaD~YL34dPP;>({;EkHpo>`@>%R9~EC0jeqo2T`0p?ONWy9T4FYlmMu!ip*41CP3*wV2&kw1HZ5k7*SG^uBO|3ms z=bKyG+B`vwRXUCO1LN}KYV(6l>YMw5#;HCoO)W*-cU0rOJOTBXu5Wva`rxVN9z*t6 zpTTdfD9;b*lAGq~^7#>Ho)><7>hrW^eymcHi~4NEMaA>u`;eo&b-R3iFch)8F7dMYM-aD|EbRM%+!qb2^J_ zxP*Wxavn%Jf%p0A(x($uUuS`~Hn!--__sc;xgS=j#&UXq_bnf9FB1}hVNFax^EkJ@ z@ugbcvhMM|<>OjcjAKqU*o2p8NApOwR;RJPX&$XGegPj-ZFyfyD}H{-@e_}>4&Y0S zUqeUL+@Fo{TlTo-hObZ$4B)c&EgwI1#re`aezoH*4S1=hdvm6FO)MY7xKE~An7fx~ zO7j>lt5O@udtYOIo?=!G!Sb&yK9<%AxWTpo%f8+z_{)!|}{xKD-Zko@I5 zb`Y=t)QQ!(O8Z0MX>qD=I{Z-k_n&yz+CFJ++P7iv-VJ?F62SzuZ>?A#)TUWG7v36# z)>JI!?n%q^u<3hpGw-^l>E5O%T=Q`iH?Mg;Nsjl%or$34&HY8DX(hSVd}L@G4N+kS z;VC9iPX>lsnbg|zyAbW~4#T(uq5pQluolKsILqoF=V0@@Xg)M!F>y*911?{J1Z?Zk%xE^hs+GCCsJ$1z zYdK%pGYLMMMJWOkIJ8SMGkH<%o4Ls5ia>^pUwB%424e-6xMnL7Je&lViK%li+&%?? zE=}R53i5ja+b(3Pf4T4r@OzK1m?)fLLM}&~;(DjmPEJ zCd3~~vvC0>vu`qWmT1ix0R=B;I-JXX0T~54mgh--wqWWDj9UR%WnxxaoQHaP#j^wN~0ewvP=ohvg=n$rJ;J>^p{8S!pFy$4Nm~{Dx_E8s;~O(Ac#Qs_p_^ zGdfMW#+p4|*RUH@UMm)0!GLT$J<`#OCwLP;Kz1O@0R#$e;mLF2w2An0FIw&h-am`h z&*J$be-cuI=a-)_aHj|$xxJa@nLexI=E7OWt`_i1uWa%~%%`>szrZ-X4>o0k68jpb zA;Iz?sSV2zQj6*fFfE)C><~`*53D^5O~x~{+twcT&xuTAS#90-GZN9#0*~(fDeCY= z=ri`Ce`sFRx4UMcjm=~{E4Gx%WP*%nKsTB+Rvv4`G6dqsDQ(wo`%+n=8dvN3)#y2( zAmuOhO&hu$GBR4UhDga=SnbBeZPykhcybMXeGP*rdU3ih84DwzaL_L8_dYAD-H73b zEyVYLL45|6$XhV)NNKZ-^YmKB&d4g_-m!_Y*S?k!!5kI;6gzR*NBy4uSl>ERcPwFM zYKWrF1#Q`NVR!d!yJi>>8|!nY+J_-_kQyX-*e_TY--PiY9c^QLVD0w5c7)QTYp%k7 zlelm!2oML-HM8!lG`Vd2sP?vQq+W9Tnr{4U2jLsw*lcKqnS`#`!EjyXw2ms)1>s(a zmmJlm8`BOlsSAT*6C-}jVFyoUo0rBoBM;htcBC)eJFj>itlSsB^z1mYLKiSi`H`{} z`t=-n+e!a21gT8P06d!&vI`~xQ}t;K0{DzF6N`k4kDqh$hu7tUNxMAKwfVq!-Z^L7 zZ5TNF+Ial6dFRIuTcPk_>x!!!*T^1OGrY4u8GKbZWIe2>I-upHtC4FXxf~8Si|H$E z3^+b?2yzlQ?CLn^JYzA4o>>NU1I=Krfe%u_5*E$!%%G@_Q`XSQa)pXS4`=?X`$gJy z?li6zul=92y$N7rcX=%6O_QmVf-h&)*KBaiQ;S(vO`*@w{b7w>kp+po9{q*1) zbGdnBXtX|xlq?MnQEKGk-XB{b>os{sN=X*?bY1kPwayePW4l?wApBnv5Wct zH0Pe!$S7Akt-=B1#qG%; z8dyN40biJ-ByAu%k*8K$K9X`sT}h2?J5Xz_?6_iPxgd zaRa4toF_$wuhs0*;g}_zf&_NKU@r?YI4(UiPNZ6ea(?#?nFl7^N^d{Xi1+vMt}ki! z=BBl=FL7|X%+vPY`B=DcS2|&gDegP;ku?22J)%EnANCHL#h$E@F33!6tw%Vl{lmOv z-0*_Z5RXIp-V|=59k2?#)rQ;7%%@Afc_03oRHyV|pLpD1TFLm``rrn*MU1%}4<_GN z1vhX2S%GiivFj8qt4MeO_y7lrcOojJ{pQYAbgrt-^PR`U!{W!bMf&ld>Ng(LKhwgK&=-8G$Z9CS znp~}GZEX!Xa1GbJh;ueD%U;ZH5;Kf<4i3b&IAHHN&#?$(GGjYN5MU!T=cx5#=jhKf z$8cq2N2qHLT%fsnUVhH?De=t4ccOw?`<H^#{e&+R0<6%g%FC>N6XdI4NO9Hr>AvODia>=P<^z;jdl{WUl5tL$t2QNxaYZW~u~l^K0J*O>SX8*b*up?@?Xy9Xr zgF&JqN~?;5)1aA8^z2Lxc>QkF>~Z_O11S`bNX6zRK>=q9#|LYPf!^@K($Ye>cOX$4 zM5&asxH1XSZ1+_xV*V?xqZnp6vic_BBTkwfz60c%%=Mox)K4wUA|vyaUhkF8heBy8b2pj;cFaIX$KmOVICI@d^{_`f zGz(^Ib#`c#@=L%LnzKJc)E|;dn}XIi;r32yVpETHY=4;W7KkhHn@s)I2tsb;==K%g zb3Xhr9Ra-t{a6Lnrc}())YW1ZQLfL~I%nryui zck&(t>rDMRpIYC%5B3`xn|4CJ=sKqpIs?HnXhk3V1-8IzYd55!OMnY(VH>lHwFLeR z_`nG`>mxa6178xYVj0#Du~cxzSFOR}3~60ZL446L8n8DiZjakgJV9h~GDL_nbi9PD zA5nj(QB0?cjUO;1h$S30imPPf4jFY+yrt~>md7~07WKFIe$ntpTTiBotHt!oF0Qk6 z1D>#k{MQ7}oX_v=;!hB}4uB}4^ptEnP)?GoC1{X}=nEWTS#1QN6Y_xY1)~dZ{-vm= zM~yCpeBxJB1xXxzFMZBu#w@>hO<#CN09jq4r=mCS)xEo}!huoI1CMz8Drzf;uX38c z)(;1ZTfE_=h}z?ct}fHiI(Vx|m>;7?8Xjb30EWjN$Ak+@u-({4l-QV*9sS%od1T+6 z6fcbK+&TISWoRm<`aSwq%~0}{k+Mf0L8hKiG#bJKM|4klq>@(*ZL99-pMqMW{9=A0 zKM%COO*6GGDB7H6s+W6vyq6=+Yfe+XfV10hbslY%YiHSBJJ9^u5bbDaU$HVkXJ-4) za88o$QU%16wHMQ7iR>v);djBe{03@nxuS@P=o48F2?rFFKaEUZI0h6>liWYOaBxeu zQt-w-{^XX6rbr#=w zn`Lr|9Jo<=@;u2~S{E08?RF%Or11l75oCLv+^n(!^HUvCCv#J6#~`*!ZIDX4uXn}^ ziv`~_Yfhi``JU#3>5c>E(nh|#^`D1)tbORgjLU;3oP+im2M(Hmg95lm==P!xy={BX zfQA#=XX6aO6*C2m37QKy#v~Civnspmo=)p}*FA0jAQq!Ttg;8@2MypuA|lRl$LW<# z&r%#~PB&Xm(p9m*U-h7COuaMXzNb%xtUxDFOjF(q0Yo$} zehL{R0X#(MvD=yRl36!0fQjeQel6x1KQyizmcHxsE>GGWI2bbg!`B|3atjmrY%Ss0 zbK8A&&C|ve1S1>iY#UcNbO1C8=RhoA^6l*FuQQ=N<7+*G`oQzAMrtKb#y+_uBcFqr zI;mpkTh97U`S7gqDj~2FEpVpp0*M$v*>9W@^i#(Q1m7OiZdJhzSE51|BVTP|AT) z{u8<8-9s5BD~m{ZBM)Y29i5>K(_iwX_zH0YgBW7MPs5lL;EV;o@&wOEpFLT^!kcb zDn-tI98G>V$q~CKzi^vJj6>ekp*oga9UH62y$sQ2r2md?sge z*hOwwl7>}PN3xxA07BQF@!W{l#FmtiYFm5)l zywb!Ehg;9x3!OSXxAXl6c}x8uT_*K|@-<`a4Gzd!4>mTJvq`i7$vwmluH77}9gxKS z0E{>X3#(kZ`R)TXZYM5=pvCT#jR}YJZ>AwmIMEDU`wbdm2D!UBwi{8TEGNj9!M@Yq zf84vzbi7rzVB@fYKZjN^wzHMwq z>-(GCW6EDJ&-^X%C*qUHWm9&!5WTt~I+b`XN_lLPbu!a%-Z=HfyfkNLz5O@(yuCj0 z2i<4lH}*U6tN9y!KJa2Ubf2-|YmNCPet&(FcWu_DzdEu0{u5r*W%G8Qc*lwL*Yh2> zb)Vo|S6@~hL@i7|Ah1Y2)6iJJdIBdaNC}A~j!?E1EGX$ikOha%LZ;B2TD{6Wem(M+ z^Vja0+y9r5aer$%oi!lnm{DZJSBz*RlZi)TvzfRkvOF#xj*JI>d3yM&gVP`L>(SP0 z!*MgLyHSm32MmU&P1X~M#1930du<7wXq3#CoP#!E4758z0!6aulw=+0c($NH zNmQqe>7{aUrVNu;9kpFcb+Hu9&);;@d@j}(&dkhYLVeNP{B!fUXkREZQ%{FmFGG!< zi2t~^Hyck}QSQI^;{Gxsd@lNJRKE(AviMpGT9`mjMhy66W5SUZwKVuInsE*TdM%0SrGbVy-fcLNh%3a-=%HI zs>y_kSnXuJ0FdXfbQ~kJ8=>g+Lb4780#wng_W;SNompEGPewzh%>KxMxw!+8e)Ci) zDzI|;YrC#}?X|mV*&a<1ts@g-BYTFAHpV6@O{EA4=i{TJqj!0-7@Ql8`PSOJVF(B_ zxyswa8GUqRZ65#DRz~$q_yKo#s5~~1NaiMrBhdf6du(hZI^4iEk;^@psij6ohXS?C zgSk`2!U74<=s$S1jv8sKLvs9s99Ch5(nxYh=8{?aLXstThOd+vn_a`F zp5$X1XRhHX{ungE3$0&~^W<`|Q<&V4p`AVj{$8(UMNuB|C1fP*xQIA53|W7rUtnK1 zg0TmpAwwHN_NSrNUtGF=&8-e;Mks1u6}j;C7?r<@?Fg-w?k?f4_p(IdvUfSBw@)Q5 z>uq14_A*Ule)5p(w4}?RyN(Xa;Zban%yzgS31>tJxOI?ij^CpIFSwH=qzph{19s+> zk`#+>Bf!I=-Q(|7DjyN=G5s50M2`L6tOL(62oh^N)6{;$=pXl}?vO6r znMf?%`g>i?W>wv%tAm5Q>-Yw@IbJtJ^S8r(aF^??uE$_!c05mP+o2QGC)F9GGbF<^ zc2W|AjS&hVDJepyE>H4Ta9+eP(2aN|tC;YNs`EU1k7p$db!0&z;(xy1UpgR6eQS?l z^q}Lk+X^?Y0gDC0&~hk52Ph-Jd8m`Dg1oyI6)Ln|Z3Z8d$mn(7KbeE!?@hR=iMw&r zvTmNe#_iTkQZ3q#&Y8N~eGTg*_w3#ci!h%2Gmn*^=cAsmk6#q}#kdvNj;t6d`Pm+@ z;zt*9kU7m?Kj&T>1{)aT0<9O6d_}OHJuqNYVH0h~ERk#wFVxn(uIy1Y_K$dx>H;$# z@RstoD=4|3D>tQQo3nxyrmZz<-)imM1MaYwz{4AM_wT$NH~AFh<{hJZ!((IN6D;%c z#%>ewCN=WAYWv?yHo4un?zXk(Jb^i{pKq=j)EHm7gsg0+_2Bh~!_z`&!Y!sEX&+q+ zx_!$&_or|~?UvKE%0ulfXXitnpb6)ypa<$7{74gGYA{pw^7X07$*CvK9b(WVhj*3s zo*;M}viXsvk?6rBM!pUh-;Hb-`(brG0%*U=buBDrH^TmU8?xfv1<^8<5aze(%k4c=4W7EwvXu=-5;fM*i&U6 zx~ELf#JM8&N&3ADZWWDX{HMKG#zOQ01*l& zD-3JAsmKN2T4N3L9B=Q})p>dXHbi3xhsyqsV>RuR{qNGLc4*K5JhB%OF|wq3PJhoG zy$-ZGQ&o;rQ)Q3g#E(@R&2o&ud(u#yZTDX^dO}W@QB|Er zEY#WY4PCx%!(atB48S^#4U&&*Ue1*xFH%lBA{Y5rCMsfu8qiv$tyuC$#igxpf)Vhq z674i9_#go_Y4xPyE(aBc`ps{usy|w>uVPmLUdzEQl(XsC|1Xs6)_*f4`gal;f-TCU z)#NxWl38F|s1q3_F^P#M2y4C;=VawOA-q7Ikq%+gwu23mUWdy8(-Ms$y`AFw;E_99 z&7RoSbb<;u2T`+2*Fq@m?X#%~&%%>N{1MRC49Zh2Ei{9or)2f{vs=8XHyhMcMfr8y z?+dm5Y<*U!y@ZEhmsI5BY zn0`H1t!yE=1ETmc>L6b(a{B&EN`uIK;_zUiPwOVA?AJqou+j~*}o-b zcy)>#%0bx7@j^d80qz+h7pkEiMt1q{C-e1_%; zg*;lsjRPHL$w-1J56m?ha{x6QuCTXM>)1K#V|^T!!50?5HEm~!35khg$^1+WRhn$_ zyiTa;2%Hdc?x>;GJ@-q<&F63Zy}x1Va0~!(S>Ww)h?8eJCK#;WleuG;UV1F|B%Q5P zoNa4O$~w#I6WX3DiDSr)eO?>`Rn7&;9HI-kywGnN1tV|F<5Muq{@|YSR$YK?<)@=_p-&K=_;z^1r4K2QD@~7RVsr2F}#nU zYs;djA@ooR*^U7=;x}6_27~o|9+WE4`^7s|x3S_?jULk<|7?HUZzhbCcg2XQA7gdX z{m)oh?+rV`)-#_KDs)n>)zenNZw3258#Mj>UXOk=e&70xR5me=2ERsrc*88g=gIH6 zJ-eV61EmoW@UXXyN+o4km(Nbod75x#q%~$u^;hO5mKi!ndioi(kYOvOzrh~#_qhtGgUb@Y6_*Xu&^}SGdI~2@a2O4aOXYJiK6Md zPj!#uuGT+WqsOL%vS;a6wk9X%lEH%E`-eLh;c_#((4iaBp#6+<#rQ9nE9y_V#2>Nm@>cLEbL_K0bQBt&34y|nm8^nNFabJ*4hV^t;HCim z6*Lu2X&~g(#&r}GEkHM0DHSbo^fzM0~st0m8tQ*$fpe`Wg1Ja3bMXu~&=@x=Q?aqo@r`iyNMWZ& zWrbGd{%;!nk=Fl{D&5Q0E92gYta#_!-F_{gdBm@^=0dR&(*NYJK66GoTT(9(xB#ig zPq-L-6m7tl>Qt3-2OJX#&z7^$@&j1_Reb0#KZL*fpZ;kbyBB$V1%|TOp*u*$CdV0T z`48Y*pF*1_pjmb?#*AYL*MPlsbOMTEa0O^^5-W)93jOO26SZ;zp0|a83AH9^ocKGm z@2}9Q`34BtsmhZPhe#7gwKsrGu#W&F5)>WbCswocR z=Rf#8&F{|OXZQRyi-D__wTcpRA!MU-A$=Zrar}4r(B~|mV=J^BE}37N`NoPyY8>dF z@J-@Qil6{SW&BwwP0`p$l*&*;u5<_s82}jJ2;hYtQNcdjw*_nfkO{@IJv#xfY{Z)k zl>Moc|CVKf4%)m9>9I1g38i^*Zh7wHgI|2G;kzg^s-vv-D2hW*<`b6M(_c#8pDy+H zs8%9B+3Zhv`x7Se!T0uNq+?$6wnV=dWqq@F0v>bX!3T$Z#lF6xufMP*9C8o#kcPNt z&>aeIDM(&kk$$hkSmgm!_$Dh47R5{z1-DRqb|wKE4>)OA&mj!9PuZTB85s+!+ut#6 zAm91Hg{kAaOMyV(rSDDTbAYbgcYB8uVAgzQ7>oQTn&m0?Am_Nd_uKhoA=r8YH2d!h z7Lo*VL~FmKybFG6q?4Z_y+{n2WkM_jAEhAR4~5$jHr0=U9bhz5##yqzC57uMw?Ehy zO21>K=FzW*wqi$bU;47l5B5$>?49_Y<@><^W`Fy!UboLP@B_c~53lnChXzOd@vx#K zb4wm@8ka(u@Gz5p2-eo`jH*82dtF@>p3$dFxFSNQ{}bdP_yAzrfW|COR{T0tl4J?1 z7l}e)&SB4SaG+X(NopoKK&7(CAE7=|x_|4N)quNb`W5Ta1<&Zfj{fm#$f}PV_~gh? zwe=eT!|zYDN*7mhdT6q?>#j&>^0f<>gX^94SvOC6yxzu+OGb`lXFS`setc&j)DzZ4 ztZ#fWwq^H~vs-ra9FlFn2?&P%R0@khh1e!Z`%!upwi^3OET|-rU9P55ABd6qTVE*j z)1eOy-ED;HzR=b{-*_>#e@yQ==?f*^yFYa}b?tcTi2FnC{^PrZE99$(jk|I8PeUPJ zZ2VeW-v3^t0Xx~FkL^zt$NK_XAEkSD9Y;fGeQ~=m%W6KHS6nSLwn4at^IxN8WD~p} zTfB=U3?db=0j7X0A&Jp#XL10^se5b)5N9iQ4*ccp?x%K7_WXv8o8Qlntn^6 z&y0&*jF=wJ#U^pj?#Z`5l8E%B)Xd;5I_?Q;R~Py&8~B7I`wV;1rf33Rfy+Xf$#My$ zGGR0<1rz`x71b;wXIwQw(_2OvEH=|%W)pQRlNlhf?}}PO+>810hHY0Guy7>GhW>Jo|7@5TrcjdpA<^$#&>%9o>TI(2MSgs;Z-8XwnutR zx5#BO&Y7#WMS3iEMMQ6Uho^63D>R2L4ZiBnA$H?Sz_npuThe@>dKa~bS(QYYjtFlN zx|yL7O~A@X{)4DklBg3ApNYcjFC*eOo|-BBL+wChFdkmKC_cFB`rhFFeT8q-EADG5 z)#>Sn4qiSy#dg0A2nj%X*YchA>c{Gnsj;bd zZHs18rJj_U-K~4Gsyhx>A?@`>)bBG>pW~1h3_mg#P^ouPQ_i9kN8`tv^`;`F#-JVX0u z;7enFpZB-^8$aZ>*puD(?tu^7gn5UG%A#g{>vB;%Lt!61DT%}YqRbrPa zDC;HQ9Oe7(;H^AHJDE3N*`zZT&GB0zW=wjacX*z6dZAy)v9druEV4S^61Xz_k#RSG#bJj%!sIebVXME^UqLdfZnNQdVH^O44jIJWvFx0K z>Ym2Lc%gl20c_qWz72QcikPQMxDhO%{VV<{?pu&UC>q9D1AncittgS#ozuDXci|B^ zd9U0;v*J7pFKB_oJ^P zT1MZK_TITp7BISwtTw!_+k3K<@$YDq0fQLPs>n46Miz|)W05vumA)_1rb%YoWjcbM zh`!UNK_@9;;Y%wRxn~}K91Hq!qeUC>qW!IXh7O#M$l=%cbL4gx;K=~}AurTQ681W{ zF~C$eGe!KG?&w5nq9y*vdqchIcm9W{s=c8b6_@mg6T04v1oKV(@nB@_EUkpL3_;J0 zAP%EQ){?06NEK)wW<$Ki4P~g5hBPUql#u8N$hISo>RI_Vac{tXYMx&Izo*ArFUK-} z8(UpnLUfSW(v?9~G`S^5l|vtk1Y3W-?IvVzK0EO~+`6NqG4jB$U3SK{h6wgWcxiVyX`{LASjg(eX!J~MHI`{E=Zsl^ z#H?ZOL_<1ON~cN)I1p#xWBFpoEygTZK$5!%A6cLSF5*O3!knIgvoyqR5clSlx83%c z+io4ZVrFqsG!Om8wv+eVb8_2nwBB1AnLT}ac7(@&P1z4UEWrsla%7INYbjB9gpF+g zEMULEK_EI4!~@>>aI*jyLARugrbFev!5sq+>KYj>C&n9!zmHC|UW5a!C>-4rOox0C z-S@%riFADG_pRVBP2je*iMd^4`B;4F{Y%unrSWYi5~gpM>VA+cBl|@;;K_rod#s80 z#~mt`?1I_O$Pvl}Kf?s1Ndj- z-??3DJ>Ef;C2f9+&$K=sBGiAXV{7Yrm}bX7=crZ1YCNJFOTyB{U8Z2JSF3TG`wg8b0d__OYYul;*NV}Cb zj;K1gVI60@YJ(Y9Fq&9wV+|;eU#;CX)>*ZG^9rrk+k6Dkxhkw5d*LYp`sR2-muzzm zvhZWs)e|!{T6-FVCo0`dTruPk&^nStoPxa0YH0t~?lB{HOpo|q%$&+J>#a{jbk*mL z^wf`Cx?`W3n+#-1UrmQXXwb>~vCF%@ackuRi_^0A5b?qhYV3V2A{hZ$YY}(8i znicEq&n0>b?u0*JWX!Zz)w0oRuZ?EmD#oBD8AFYK_@^7}EFP}m^cDEj@4&j+kCo+$ z?{e2C+~_`0bxWn*IDi<_& zAU8KYK9>u)g{9{Z`KO3kYyuQ|qFC9HLsMe|Z}WR{>5;8rz6Cja+^y&ATmE`5vz+;4 z>!+sgyS|r%YWTn7n}|*+Za3WZu{u#tG}3(6eubp&3ckTelD1LX`{K-PJT(OG*8v@ys`IOY8Go znfEiRIF8*IyD^TNe^>V@weYa+b?+aWMY6?g9S%r5HHp?Y#o&J2@+q$SEBzZ~+ohI) zRMZ#HOl9{4wCODu&;X`&Hcp!3buLXZHa22XY?}o~IUN!Ni)ny^O%Y|XhxOqE?LI@} zvr?(7&^||-%iZX#`>VXY90o)>2NVEoBwW}rUV0fvU=H|xA|3_`BszPsWM62luq~v? z?Imvt>8T`F+^hEoU8XTGb?X1B z{o}}!YvLN326-G))c1mAL;}A5tE+n7r!Mw*-Y^DSOZy=YNc<11 zz-d{ok7097X@KW5VW(ru?7HS434H-_ZFcahXjKOq2 zfU#?Vk*$bktFe)FZGyc8?*&ZAb|rgVQ|>8}_10mha}AW^HvAMEw$w2tL!ITIMP$$_ z7vfH=poFSr8n7HKU@#O!mf1G2lJgx=TTd`jJhOP|J@Uu_HL*%RYOOqWd*29=e)^n= zWzeI(&->0YPJBB0TZ^qTHhPr2IP#H^978%phws?HEpZ@-G(eHC;LtSEaX~=?EDx>R z7GB2=zjQNH=pT+GS|6q6)F*QztHG$E#C(V8`#njx+V(Uza!vDA)_gM@Ft9jFsbs(x z1P}x+UH3iVu^xc;icV3fjx4bs827-$S~Y+G?Z{ zZd|bZYQS^NBc}NpGldWDzI|w56e9um+;{oI-zH7j2XEI@zv-=dQz6eUNB!Rp``<;E z^`+5)LzX9$!W9$YhPQvvpIm*>$JuKCH`b$%0L7)Ms=I z)54P5dgc@&#OFEBVRiPkw%b4M$7IZpUpl-eOyW4qpTOY~HfSfBeEx6F8piSCtlz;-#*Z|Cdzz3~(~zW~ zwLE7IAU|uirV^15V1-6eMxYF3HQ9ln$FS#ULyoED#hfFc&#)A20HF^fQ6oN}DVFgu z3@ip@qfE58xC)V~H2p<{O!XQ@Zxj)jtBZ>mHe(WX)%>g<{xd?pg*1s^6OTvqtp%e8zgFm*Ym(KQv)Ytlo9>9j4CDV`Hj@T zcoK;f&ns@XzP~}I@f-`HPMdAIK+#o=^WoHXi9h)h>GV!MS1KzKD?YPfK{UfRFdu|WMA%T;SQLUH zGQjy%>od}4eHFy;pT42%llNR<3}}CNxmi&4=QVv&tg^a?HXjECg|a96ysj3^%kP=g z>sMf-e*-2;hi4*vR-P=_(OQ0;W0E)+{2ard+!yNh*g7(9PBdg3)V;Rn2KHyjPs%Ps zg8+q9#*N^I$TmP9_*GTt7Hj}^9lEC=s0H+<&YE`K5e0uFRS57|I4_qZ*ab|!)^J+Y z%|~{;x|I`)+_pd7upAt{<1%F9!a2MVCst$0*O3%#|Fg2>BxU?}KLt>c`Jl{pV8zxg zD2}UK3uq%p5QPoE{$ay-ud2khcw(N+Yw7e`5`27+@$1^$1VfVqKCwtQoW;6Y#z=aG zZZyq0*o`sTzwBZROJi!h{Ii$;Gp}XJjW*oYp55FYUF5DIPlXA1rpchUD57Fwnw?9M z_4D-(i%6#_i%CDg4|7&I{J5Ac z56!lgS3qbTYR5=R*m`{eD*Uz97%STd(O&S9IAe>RVhbSPW8zbo7EkENp@Xi!3V}ccKaJhK z#6nYsw@>?STB5blX(x6pepi47tgm`>33dmG@kf~^$uS^mPxx=s9y*%$4LyC4!4%dz zfI3vtc8^2UK+5ac*}*L`!0C3Un^@%N*#ZWmxuL`Imm53yL8glTvfj=0K6j(;zr`Po$kG2OIx zMj>NxI{XU!p-`P{R+0<$mk9^!gRy zJrDnGM%DN7ZF3FO?nbUjIP%sfc(4AF^bsDvRK@Q#`n^i~S^S=yi5;G#37jpZ03FF* zFzUcF_FkgunjL1;y_aAq?{#-!BJufT5@AO4k=8!vn><75RFcRTBN@LngkNM32<5R3 zQgE=nr&;Veb`u)gA4=@Siiwb?sN z4Rd@mH{E-!DKvNMj9WFuBsSOLgz8rMuV7vetl!1YGyjLL!gXkyThrXzZp7yh9gEwT z*(2T$w}AnquUPw(r>%*)1%F66b&#?har5j^2jv2I%??m;KnD9=LVT}~Y29?S9t-GK zk8j~w-O{nA>8nnp4~Zx8M>8*7tp{TI)i+O684$Ldv(I2{0&ZV=`7gxZil^b@%$Y_d zT*9@B_4Iajzj|30-&YAtyg$y4PWGp@?7w*VyK(W64+@PT2$u$PN3%Zys12y<5BDAU zAfCT2SCZFG84v{@>Zpync`4f#SBmlN<~wcepu`m&_suh+?t$B71sXjT*z69xjMVJ*jira`9#;XPTE5-Qh-fH;wVW@WMqVW%(x53wfc0mNQyy;c%0Nr zlFz6YDLX5$ZfgaUacRR|7~)%5Ak=7}^*|tsgDe<|Sf?TZn5l^m{vP$^TAwzI7m0<( z)5KLd-<=*1%5_>v)?(7O1B%)l4Y#HJjoV^lQ?yPx)7Wxi`};Xjz&8+*g!~N>hXdKf z0UB!_aJjyg>3d`Shrn9GH&4; zMI%)juO7Mc&Lh?FmtCBDeOnI#l#}70?}&z_MC)T6e#vgx4+;hin&UZ=u;q$q7p3Zg zW2oZuVFY-w2Rq1Hhh`Zo0P>WhJ6qqg?;6x-+(BaII;6n=&X&o~^Y+X-@UtXuFNBKX zS~MPppVKLjZrVY@;jbWLpv?`U&#{KQ(17$}29mrLA6{Q0t;YH>32M*DEU`UUUwA0- z1pMJgg@}Ze4#R3AoX~%+J<|Ye`4Tk;>*91JDt?Giq>EMirA_rPcN=Q`?>5#r~>J~ zL~c28T8Pt$RmzBh;d3nzU=V)CT*&tYsa_NyvvXBd$+rbb;`L;?PRd4%QUXFkrwX_R zs>2EddK;dcGxf1#qj+tjcZY6H3=AQ6H3IYoCWPG}+(OO5`v-G7F3O>B)IwoKy#e5Y zzZ+CosX&igFF(t%dvUC%U7RVG0l;5FZa>4dO>(eDF*M3V=!m zF$IY0BmN4mzIiq>fI`Bhe_8K~J`%9L6%4s;j^>5cpMhy%6hOO;_nfz)+X8T_bW z0B#;teLuZU^L-VuoXRhFG~=s2?Yf`()T_K9t$Y+IK4v_g86^BTTGm3|+wL%tp}cnB zlIb_#0Wrm=-w+eQ5J9ItV2Rx0UeBx&(Di^a>+wFG6BgrVXPzU7X@PYRb3DcNEx5DW zz*t;|J}3hqr(hOrC;*K7#ha6Q>&>DmXIe~!lt^$WsO#87l+Yzuh<`z_@Me)0%ZYc( znZGSK9`L!TDQ;h2JU9fwSwQ_R>GDk7iRTKeax7PnDJAZMP+t*2iXjya*OL;FIhW`_F|@IDwGP zi5CJ{7RdE!K!>dYxB~!-)r_RcbPmANd1oisrraJCkeh>trs{Dw6hjf3;{j`*XtZzH z2+EInOqL&(OwR(={Qu2>yvAP^oJ*bkkbaG@G_~hjcji=YPdvaGV2nmfy*_h%@anL2 z9vt)oXu$k{K6AVv975ReP_yCU^K$CXIM8u6(i;hko4T6OHO=GGjX)Udt^1@uf|Cp` zx2_|LoJd82J*wYrA)MRo@#?A<{D=|S{50l_?aoSraqJ|`n5~m^c!{=#aNQyZD#unK ziHF-x3yuweyS7uR`PT?@k?(EDIbdA2O5fVofcBbn0-NJ7wlvyDQb)1zDw@oA@Jq~J z#9sNqK-(;DRM~&*v5u{Pe|lF#&Vt-*{Ns6BZ%6rjt%%mkw30v? zQ96oN6;ka}_uJ#};n&Q(MisjHCmOOpg|*zwCa-V(DQ>E$y7*<>(W~Jm2{-HSKkq0w zmTaQU04dm9<=XFR#pk$xTm5iz8iQ|>--M+QF+t}HL`x=EyLMUkdf_nu??!@Orycq8 z&qk*Pk}aK%1!&`FtVs>PqV)ORtN|}F$~eW_3x`Ff%#tJ%3((d3PL3-%-@(0-}S3~WTao3vq`(;l!Bef#h{?b&#fsn2uU+PUVvpi8bgGC(;?vNO|s zKaxcd*A|ZH4Y5U$D^l6EA;Ek$3Obl7#TK9VC%GUOHYAqsNfT(K!Gm?LN7!99Uyq)y zwV0eKK}ZL`2ycPzvG68Bp+snfw~u&xt?YGKtJhn8%O->I4&3Pc-RFz@3I%K?HsYxB z&JzW=+v}2@XfzZiZl^etUJTRSZHrf{9*-BriK|s?kdI~KcXg9CwqDx&-u7<^uE0~Q z0jI9Qw-0m2c5~g53ye|@s7?rQ+O*RjC0?6K0Mm}{<}I*=wp1`)qs08Vo)Gd zTEE0gt+!!tvlhU=u?vTGzx%(OgG4v>{Y#tR&kgT=#h!QL&hEGW>!!$aV++2?o$th( z9q-R>KJt#w1kw9WoZ0<;=#*E;h1Wdou-T6@c}dH8d&oVE_<15|VJ z+%b729C^-I95^46+&KSU2RnvBi4Fw$>hp5}xbd7eUAYk%wh^3-EY+@wdgKD|BKV7E z72=O$oNbu6cqVG=s<(KX1qP2#c`(3*LbBNjS-X^a_NeM*eiQuUA|2$s>e2gCrPZO? zwNz=BN7H6Xsa3kL$om;^{JW6Abatr0Hl;P#&{mW86rWgF(WO4MyKgt(199-y9uGm7;!fgra6i*~W#r)8R98%X)kQ;-Vpg zwuQ11&^y#h5Xx-1bfZ8=+%ptQ5a^hNjdy7Xk>?$?Bc<@l3c;DWIQGO_K>Yr`cc3S_ z+h^$|E2M=yY81)BygKSb_QZDiEUnCEg(uo$X+F*RF{xx)4cg|iqq{9j+-_Nov1|@c zddm}kECy5YycTtn*FGL*A&W*>w=6w$z6-4%VI=5j*pz!AE8?#gf`X)ZlqcX1;XgY} zo+=2kMt}mWC`nX(t`HlEOLiy;hY{-tg)q~+{w8^do15= z6RKvcB*IswJaJFLbe?v1}FK@q2dm!o$h9A(b+y0bpRA%ozHQO+Bj04$AocQg7 z_|9wU-xafs@~MvI(boN3P&9V`Ia;89OVsthoTDB>AEdr^k87DEW5mJ%v`H-sz{Id^ z?y#%WUfoIe-t&3oV*SJRwFj^iAwO}72;pE5C`CBb2j-&-YJ`ZsF6mAscgDTm#ExXz z-Savv0E6MHdJli~4L$RTn40kU5^5|l-}46T{0_ttAM62aQ7%5@hy*6p0s2+MB0wk4 z`V7*0)Gn~eYnRl;@Wc+lbYt!$qf2oCLn)fuj(hi1hgVO|oty*KMasAH46ZY_P59*9 zj@D94%?vm(%p^4y>lK)le4ERJnBou~WbW{F6w8a3yqYO9_ZHY&=qHF`p@Ho4pd^(&t+cQHP>-KAp6a8 zB}`|L!?aH37{JcYpw!xz8JSU~H*3qSZdeDP@ESZ^@2~WM_-?HIvM9 zs^j}3X56BiI_8gR?Jcr@9??^W6Qjf`>zILTFJ0J$C%e3kTK#nVET{w&HC$*1Jg$tj7xB~3B|SWQI?_W z=7HFbgc)>)(@5ms)U2MsJv}Ie*jo`k)$--N;u^z5fCKeQ=10;InMsR~_X6L|wD=UW!?)ehnZC{`I#fP8h)HYH1j4lAXk7Nw^b$qQKT zMep+dL9^>UV=*p!s{=wlN3WR%i2o*Vp6oe$BGJ#s`06S=UU{tq;FV;wIvk{|No? z!#9~YK4SM6)oe++QCkEog2c|?8^p6(B-Hwaa9F?Ah<+z#eB+^XA!`Oc6fm=ee-8C! zL(NFE^{?s6@*_FZ%#GwPb8ufnd^VSx|0$pY^V$kE~c%T1U3Nizb|H*Da@N<&;y8|%G_{Mlsjvq{z;DSc8< z%DVLFx?t(k@d<0fDo%0?v<42LBpg5%dd`-q#S2LcU#-9aEEH3btQV${ZV~bsl8Qf~ za6Ulg5ff$+{rK)(nY(pSS-R}xWlI&I-<{dD`#6b#k88>@iF(V5_PC|bToJ$M_6L;Z zqj%hKbXj@e_KV_IAeX14VZ{3ev5IeUy$&nc_IdF*zAsMhjNM8isKC{O5*PxO;PuEk z(~)=_a1Vr*B;i@p2hfR0g`f@AdSn`Gv!?c$qzZjD!^sDwyX{T9JFI$HSeldt44G^JPJ!DYU8?vFQQm`6Ue-p?Mn~(y&-m83VHp7 zbT+HM^moHbTJ7oes;KD3ui$NK$=3@D|GwzNO}XBKCww6#7Pu}JoZ26c`tu$kJb8aK zzJDqhyDkt@LcS9Rdvgmpzb_as4_H{m@W!wP%JHDjpL=>Dx{m>pd7@|SoFx?R>5A7X z;JN`{1En@&P=>=K50Ew?Fg{ZF0@UWm`BsC0-qn;VOG0!qm5i)bQn zFp-J%l_CSZrY;?Ka}Ola+5t?kcrW*R$Kg^idBPue3$IVTZcnW5mRM$B7i09T^;W& zaiAYL4&R8#eBSHc*5{O6)Vcpq0-s9WT) zu&qdrivfI3`O7hT|8b-6^L9D+AJ9ecsy_o=qny1-_-8V{c5$t0%zZy1Uv0l+)lmtw zmuy@9ZKF!@&(YS|rc(iWYWuZmS@OB{D6NeK3%o#$&Y}3EC)#YCwF^P-NME;2mVTB= z<~webhrruz;H0L~0Gu|!FI@!~bvU68Hnq@h4K$;q;clb}lzLEt-cj%IZOeM7Z!8z>@t>(@2)C+O9>AN?6)z2{xOki#Gx2 z+{-gg?AA=(&Lu#EXghn-(9}VD1tJay!-#FXMe*($lj@XJoz0*1dejo2y;U_A&|*$@c?EVc*Enk9rm|8A4hvY@(^(k|%tnV?9Ij ze1_+74eh5ol|2;O(LvFHJ&VXD4Rz8k(KzuSFOCxrie}^PD?_8%Q)2PK6DtHeD-WJ% z99uSbPEN~wt#Uj_yYI-A1#&C`ba$qhXs=Sl6vm}%O>2eUQDK1O!yJ_p;Ug!My5o*k zb8XF;1BaI|W_=7ZUjbaK+h zp~GiA-yvPd(Isw{StFEkv^{FR%8`8H4E4OVgtW$Ad~oj{%RKKgX77vKZ=8Pc#K}2I zn1I~w7T)s$+fpbuF2UX(E2Mlc&B?m+B>0Z zN-FEQ&zcm6g$t?I_9(448H!&q#5K`Jm=CC28R|1ap`I9a+Mt9~b(n{j|&E7HvuI5nh zuHHl7MYf>LFX&!z@H`Ej{~{$Ox-N7gjvK##A8bRRk^9fvIJFJ;WPx$=hIBw7vtyk9 z$2Ji=p4EtvJr@RxaR6VzA%8ZEA#_6dghEAXs8|i4Yt`aMtKW+ zKlz_(JzDgcG0T7W|3g)3yg%%F>3iZ1f0%LYnE@X&Na1(sgZ%#hlc4kg2e z5l+6*cU2u0Gedu>ewFWyoqbEVa!%jMf&<>bhM{jvoU1Y^=k$!-E8k0><37F1cWBFw zD~9vU%8RWBVwoedKbH6^_vZBE&MS5d%|)xotz*@XP(Pp>f^Gx_K|VY2K*b`5DeZh;Dm!Ue ziCm&pKv!DH)-Z^txgPo%t7Q2sYln4qsL=Wizydc66*(iL+~PGZkUr!5YvH$;&>W+>la*)#HKv3K9Q#Ef!9xo+Q$^ z^m>qOTv$nunsTln`*@Ef)RgDlOx*qxclHukvnZRCJqRFvZjXgJwZiZrR5IfWTc)8N z2)eaguSW?6^sI)QxkAh8fgtjk=QMXP9)HEF6{d%G^T4&9hnd{4M-#o52F;64wS^8n_)Y|nKDo=V87x(;Q-D!F4mVMxn{qNR=*Z6DPFf#8c>AKx6 zhgVF)BL=Ezr*PP2t6{y(gqO%I1(R8pF!@7{i!N05%E;Bd6*GTvUU-;`tW3;hV!tDX z(tV!fNV3UM&^PR@^wHwztLzPha?Da5pF`~qh#BFzx=|Acd# z6wVdI+`xkZ0Fd!dv)rNaiX{lC{T3ax8E`XX4wse*amK1#5k#tHFW%c1x0JA!Z)TuvSNWQx%bNV&C+BIj8%lZ*Go`PW1(M6={aX_WClnAj&H0{}uJ% zv_!$v2i4d9Nwu%9I+QP?A+_Nwcy$))X>ERq*Jjw20FMFRM#Qb6LIn^Vr5?fhOH$z4`5)#qaQ-ur{Ljjr7(}uT^GHs;Bin2i1s=Ulz@b3^FZBvg06!2s>H!{u?dfP zF4kU9TvPhDz$AKAhky#m2Rm}N*F6LNDPOSJ@K zkRV%nbjF3;CA!BYsl3Uwt^>`xHWCRONk?AG*<&B$QORrzCOZZt9KRg*oiK50`Uv24 zALod@l*i+XLz!(+dpsQb7U2b=o#4pYc&QtD>gB5L;Lcpl!2mU6&R)#rg3Qf{oM4>X={>j6_yoH6&1NDA$K;^{~Gk( z@O^6|i*4}iXZlzvz^$GORdNlI_AOg}4r1B*BFp`&6LTn13<@F)(+c~Ne&IQ#A*1_6 zlV-l4xM5P(KsrUiBFgCvsXxsQsdFUvG*Y*{B9*)#8p=6=E}om94B)*ALpg=XvlAjzZ z<69QFZ^!b1*0)v&Sqme%V;TY5L$$35Q-oG-l?NS{nk&v&ynW+XB#B!LV*tqFL;<1{ zDoT{mv7&1zJjFz^R_-1K?mX(uiSu1pq{5lrhjbKYR(#q$$%*_Vf=lJM_h}d+4AY{O zNyjh_(NvbYKf5w7TvlId<0*`(CN&;@D1(6iRZZe4bn_u@t|onzXZ zE-o^$74LKzHj{ zInK$MX`HL-DE37BD;&4M)pH1Pw2kYko06IgBowm;cI7-MzGO&+fA*nViEckRlDaNRG9Vy{=~LWjOw|?p17Om z$f>0^OmtwgN@^r4V!XhzV{~4zf|_{T+@yF~V7Q}rnxSyyP4nAT$k;+Y*_G zgVa0n`!d$|ICwSWOCe1&-o@-P_V5{NQ%AIb0Gbqh*iif8Dmj$lhMJ?Gu8A|D#o|nH zF|<^n0jfxDl@D91(bp~MCTste{?v^8aFHNl@rM!fqH4!&d|3Tf+?Z^d`rD28O$Y?w zbtY{QR7@sn3Ac2quL@_Bw;3;b#QhXx>Ht^j|ydJ~jz4w&d?bO5Jpec%RXkh1= zvE(%^Wws!=Jcn~=uN?TUle|&Z^BhiOvuZHputX^tH&9akCK&_rQvy-{GbkcrZgsx3 zJg>Sz6oxXMd2d=f8o^QIsFw20qe>>ze{4|J1r<@g@)bb}g_wo%t-AO9!35EgMDYDy zT_3e9Fysd2NVYWuJDIG%K^EQx9!m+?M6rN)%FdXTyOJ*>qxs_*7bD7pVN^L$8nMypvmws#g$9Tp+^11i% z_6;3dd4|``8hHCvmPh+x$Ky(71T(-eG(W7Nb}S*?&F}7>Sf(Z0YSOmKV9EqJdKVwd ztvo}ZBkvQ9bP-FR?)WX<-qo>{XTWojjL_9ihU^PzrPJ#3weI6z_eT?6<@f|O___;2ZzTtV!Tma|3+H5%e>z)ngd}j-)9ew5Z+9hD*vD|KK zpFKB+_?q0xk?1@_<3YG>eLSfB=d|2uVfX&r7T(3}l-rH%k)YBEBzQO5VLRaCRo{H> z8=v1~ZsfaUcRSu8^Dnh?FF9H~JCLr8)&6*<2vT<~ZmAI`-x(pjJb<-Iqa&Bj}I$&RekbJFo~ z#IpSsR4Sx=FnG9|SmL$@4ex}BLE{SJiSPdv7o`RUx;RhRpkP6(5zl3%k!6-j=Lwf(dZHYNbXD2+~ zr1x`L->{ya7`y&5@mTqmtz$-cD%Edzv^mOTC)~b-_o>?5Big|DSe~3!6v)SGN&|S5 z?aeZ{1sp01f#8J7$O z7iS8n(w!@makMAns7e0^YH~HIFg>6!Z}b@P%l5LBOk% zr6J;;(QGM?J90R@Kv0Ys>`s+7WL=TslkE?HVy#dP*=D$6jlN*E(Bm0pm$J7~oS1XF z2i@+uiDG51GH&f13_)^MA;AP9!H}msJiNVrX?^?faM=?IMo@?d*EB6OxYufZ(NicC z2ea{F3~uv-#X`X&vaw=3`zgb?cyjN~v2uBA=ibSS4dZu9ML#_;h$nj>-G@KoOQn1t z2}2WtTFN+$hx&`9){ln${$ji@ohjJ(HFNwE{2ccb+}b9_d_J?kD336w>N>0>n!hu^`0|lKWFw`qtT2;ni=gQ zjiixmSspL(8ZWUE+cB7kEVi>NN!ilSh=hg^$|XRX0R0GJ2{&n>7(#&t$Q2C~QcAB| z(m-1l^_KQRFSIG8EVpFx{h#+eXJ)k6K>PhZdvx|W^Pcy-@3TMu=PfwYWvA5Br*WtY zr)X?rP66D9J%M||78G%BqtM7`-F+?bt2quZAUhp4l1MbZ>hHvk8c*_pyUR&f zb?1)yO}edas<%dI{oYef#lP&}AiETcEVkJ^@Bj}T;)|8KP&A75I>x_C zbN+z)KyUzcV<@J16oz54Z-Tmr8GzkVbBC<#sO|;R22Ep+12y5m#K$x*`(xJh+y?u0Y zxfBgpL~I9PmUh8La0wpL&#~+Bz^j{}ylcU>2{YJ>qf)4lMN5nJwCPoAY0(ce{tJ8= zailsTgXZhY_}`e9P)fj$K+x77+oGhhDdn1L_^Lpha%qwOTb`fqRFQMtxqeA=o5y>+ z0sL~3D$*#VB6_Y4G#t~Z1q6xdP5_aKDy*91i*)$_JQd#b^GxZ8(*SH9WpmaN7>ClG z6hPs|QP@vFSG~fruHb+DLrU7>BP@C;jT}B3r?(xSFlYNU|9CKrkZQz=urKn}1H%-8 z0K*N(PX+pU#kohVN$_3<%=HU`kh*Tc?L1^B~^kT;Q1I{2fJSvW+uff)p#V1 zP;+2&!>q(C5!w!nY>4Nqjw>;k3W{<(F~SN{t`MY5BCIZ6n=Dd}!_DYsMI9WQ`|;e? z$+qnmewrvKy`9+})|dVlvH)NbN3*`}L^{iI>;XOAc-kM9oVmCjXIuEzwOg`rsqs&i zobC;AUH*% z69HzNd3tkL5P;)?-dJo_gFZ*)0Vw|Y1-xHEaKOm^W{$3=#oZhg-~?ZutOiT?70~bCG{$p^XKUYXJ~N(Qnx~maPZ@C@T#JCW zAuJw0_`K+cB|UVIWU%3lUO75H?MkT|AzX)fA8a;*WGp&!6nf;IZP(s-U*wS)Q5I)8Da_t19|CveZVxLZrOZkbeH#B09r;#ZJg z{scudQC=N)NAXZ)Qg)FQ%&F={)>H9>bQhAo8v*2kA68ZSl3AbTXujF$j8FAzzDy1K zSkKlF8Sd@l{N_t=JxgKH_(y!}4OU7kU8lhmt)A{4X_YO_zpe3+PTR-YRcH$Ze9bpuy5$X0xjt|RG@y7aLV z7rKA0PHht1(d-JhRh*v-yFAGwWT)N+4-fq!6*C25KW+`Giy4kKpiUMTJ;~UD5W{cB z%@dx&12QFiXd%$0!|)B9<>fbm`uqHlG(eRX{YwDCuL?nFfebE(S0I8zSYgyMbedkS zo1w;=9whSoAbUl~yirm9gFAe1&&KoQZ)h7|2I9Rskit|zu0g3bsgZjJ!ZyYDfK)Hn z=jV=ULF?#pd~PuQ3M*?(S{9Bj9<_qn(Ya5?2j_4U2eX!-gOF>U<~$%z+&e-{0NpF` zWub?jjuM>-)M9cRwyr5rogO0Yh*UuQc5RB==yNOZo##5)(B9K~4h&BehBsvf1Ch#K z9@ukw?{p;+7|d)ME>u|#9AdHY4_GXZj}*6uOrPWg7cIliu~cY#ab!Ga8`fgbk$e~$ zZ8jy>n*gLX-WpQxnxVFMHFz^L$tVDu2Y-32+gk%4ZG0#s8_duSx`P0PH<=-ahQ{yX z(KP=ka%q0d=yYZzGANEjq<_#@A|4Pl60=ne053!Cd zyo zmFv%&2gEl)L|7f_5zK-{AJMe6*teVo9Gi}hY(*~db!pV(4y$#x*$EaFbv_H2uuVV08waeqapkh7KdA# z=USU+2gQDfu_j}tQcb^e9Ulj^OlYMVeXt>sScuW45RPh%ki#jU!DE;1J{c&H0{}ol zasC4PR1lVOB)LHo*I!`YW69q63+%Vrg7WAI?K8u}!;j#P{@ox3_opSPt4q}X8RqakElg~$1T&Wl6D3%f5(o(KCONj^V1>}n zy)9(4FY+nmEk_Ke%HyM|j`7CDk`O=WV5{o^PVgfO}jf)bnxu4G=yENG>^xSUZwiv8QD>0^njTB0euDmM>Dcd zeLAr9(yakDFFWe(h&;+rQZfBx1Bt~M1SZ|CI`VuhosN0D=d)}Fep^EwXlMu?Mg)j6 zg-78>1^R=ZHtDk!Glc*IIH9b~si5(gJheEIjfp*Tuf}(Hy2FTV*eM$Yqm{RHI zaVvPGlTaOJU%=j;>Y25CmLF_2W&3k}@HthI*+5U%J15Mc9^05TB58Bn;4+1vZNc)=G8gGOlq-RSEGuRjdSP?n=uL3n{M#SaT*7bY9U=QDXESn znZ{$3om(twAz4ekPqG{pXK-GT=~6$j2i||nQXONYhj=FXi$*4dk>VCf1U9*Q+{6iM zS7f!ET^0HGOPayebF5)?lsfnfpkmVClp^FL$Wyz|(UBbF78I@_TNgI@0-7KA1N z=wSo?*cfwqYtn%X)hU=@ilsOVjfuh>MyXbqN+9AZUV{Y^GGqeojrG-JVyc^K zj#O(t0@$2BY)hq*WDg(H0`ww!u&WOb>m}(@MkPGhPfXbT16Je7{#vb{?MnZ?h@)>n z-VOVoAB%PM=lZ*1S#2Pj9gw>E#~zN2_4j87kOGr^D4-Ys2QuL1uf$%Dr#0=IB0Nh0)Lp%_SnBaJdTO%f|z!^aSi zzQEKV$xsRuf*!?Lx`d~7b2~F%@czKJ&{cl5=$0;cc|02qO+duzn?Quki0#O#;geXm zY#I}NYDk_4g|l&026o9lGh-V`!_Js8#4|~g4LOc-VgWEUu(p5#%$JxE2*qP&EROi5 z*xHk_DQmK#JXSkcs~x-|W-jWoT9o|yf(b)3{D8-GxS^qqE}Aj;m;r%`>s3|5%11zg z_>X{eBN&Ru%@_(l8S%KLC_(Y-K}FHxz`6=0VrD!Z3L3ElWJV@4pYjY33ZrpNwju6| z2(?ub03pD%B;}{7tQ8?#5rISi3G*HiId4u!9yW}JBWaeMn*%7NZOzSDSlO%g*kWJj ziya8leF(Oqha!!Qu5rJCcD37K``~BT-|-SowVJbzOpEYV!Bv29UMp}g7aSr&+%`Aq zD(r)YK;SOWq(HnLSlb+P=O>@R{3@txTz`gYr%r{&t(k$tT2925=Cr=%NePdDkkg!Y zcwojF4^638;Hu_XGPere20ea{-!5&f97=`bP=(^*@3u~{AU~2iRM}dx{d>Z3V2Cuo zCuj}(A}}n*DDF++J_fk^C-D#@lL~E_=uIq+#BNfBxP?^(pQ7+_wWMl!ObT+Uk9?yT zPEQPRI>X6KFcvNuwryGV1Ya71o$2s5`UnD|FF9Y&4^=0wOuY-lMm723U3Li9<8oY2 zOJi_kvO1K1AzmuQolkrMuS)1{9P7h?%nr{3e*@fJPnFhrLrG%YLTXRkB{dA))PU%a zqj6D3cVI02sfbBfM&1<2NCRq2Cmp8q2i)L;mV7+HY2Fd!6kOXjGQ3|C@3jS_;{Lkm zKJKLILad=Mz>rSqS%#^Tc^RqAID)HT3Gm|BTnIzIt#Vs5TA6bqC@4(sKmDH1+|yVV zRf)H7?zqIq6IC3JI16{U4AXBOHvJ#80{9}nEtYV)@xzs#k&&LtG`IY@H9tmvi3&D} z9?}l5P=RVjCx8GU!NL3!!QM%)ZLr|FA3HwJqV=)Es3Gr^62pRcf1xu1t zlSyWOT4s|AWk=JT^1`Id(*35y6xFZ_$MS*)!~RJa&YuG=c?sRnGFxK?2)k`U;nTr0 z;*w{why6^`8ed0J>+;PuI z6)PkC{CT9GPd{$iA$Cj1wjN)ey&QSqh2BV%to!L9XCkoA%}0Oj<90S{KmJ;F9_hic zUXmWt@xe)=5hRqbMc5kfBSCZqkPg-`SJ>s85xIkC>_+W{JW!3sJ?VE{b6->=h3LBG76;hA$$gE#Uf0;=fns~x z+8R57FWk1isjiIKfpL`t zB2ePsJU_(`us!pnG_gI6PhrD_TctBRmf4L-34orLU@N40-7g_Dc~JIMo>0VvfY^X3 zgC~_*)HIg(CAKMn7GDEU3cpylz!CG(L*4@GfWk`H0Z6 z4kz+r2f#gaeGlDXgO7l>s$dGdhF`Zve<@KGgQve>OwU0pq?(;Dyg+#&yGa(NW7mTY z+sB$yRF`g!3DxBtU0O)dGAMHv(7U{CC8%G+t&(3|e}Z4+>MnDuzvkY;r+%1QD&O6_ zU-JaN9gzyMZ#~6*7CJmemtb^JLjR~V1;qd~3ey3Tw}!wrC=d{l6IA}j4WTETuAWNI zm_~GroVi(4)q7)9WCHZGC6)DJyedIFAKA4!rVwPeK_f+PN3`b^axA+vpXUJOPqaV6+r2I<7XDEiG z?M)|--N|-!%`A=NPSZSlRX{pR>$6heRkS{x8(GQ@#~sPsEo%<$p?m@951qyVS;wBP zj7mr|!9OAI*4Ywlew5=}CJQ}A9Lc+UmM36b<0TvtQ%LLKP*t@_Xv;9yAWDq~B;a8Z z@yGx9aeTf4^TPDbU7KUGv)yXXK#%&`Ts?yy)i{@~lM+q8eKKGTYgm3+$h&X*iIHpG@+d&^`hJ~pOnGtI!*Cq{az$EQbO?ov_$;i{V14vj$YZ9yby}R6iD$osC0vVKQ!d9tlPvRZGXat`j8egeY%V? zm7Sw>bT4YNnHrl$vDHt}UD$ckzlYNrv3#*Z4I6fUC^V(ROcL~W!?c5ZH#Lbp7+_6x zQKNA{^VECx5@q77f9?pCm21*Y3|b86@!H0Za;dIc+qmUmo2BdXieLHg2JLJ-sznde zLmZB3^NPZbi01INaJ*n0k`BfrTyby`6Hh0>isbo8g7-|I3IX{MxuH(D^SBWajypBR z7O7vZ);?|9^IEiChlKSHh@EV9Hah+si1sc#|JB;(aS^q+POZI5Gw%0#5*x5Ddi}%4 zp@HfPXcPR1E*lh9PfT*HZKwz{CSE#36A%K*#M{h*3k!H!V9HU`YRy-_h^ObRT%=Zw?Or=%S%-)LGDH*z)V}3<{o&OQvUYg7AqVGa6mY2!&()fTI;>1Z*u8EN zl$KAK_Fi89JZ*C`_MZ=zVz2oHkNPXbN3r7<>H)Z_F;N@e-5@q9i6XTdA4dQ9+U6=D z@&H?PgV|bykU*@aUDUy-gsB)4iKr4EoaCpWZiUKF^Jdv9q6947Y(C%D$>(Vh4z|r% zAXwmF77L43)*_c;o)PnHH6?0%0hi>%#m+uAT(Yt`%hh=(7qq6Y*K#055Ak{EKAW@+ zvM6P6?(8^7Hn@c6CTO4JT8zwW8_`?Jy_*`3c#lKAtAaqxA+7Uo>@?7u5?8 z+BlKf$Hi04zklcZE+HVD%+8D!(~}8SZX*$+cOPNOS}F@GrOL|uvHs8dcE0~-dW?S= z#N?NuwHsFEt!>CQJPOMx7$+X@+=$jLaT*I+$lv7Qp9oq@F(stiei4)w@$$Tzgr6Tk z)WX6;orc<`a792Q7=`SKpc&}t-^v`$ydsmi>KM1Y9Xmo$9%Jv{yx9nM8k;ZkE7JeQ zk#A*i&0$=`?Q*!tXne0i7j=e>%|{%i#e1YW+F-)UO&SQf;GuX-Kqzbl9$c(JQU{rG zau}b#xLu2<3S`>}){}StaxGN3kBG59hHrQnaU3u0xUmCRAS9-8+XpPY0Hc8>Ohych zN!mqHKC)vWmGa%_3?#4%Mj;Y5akhDH zKmsdbmcmb)45Ranm)^{Pf^C`bOfYuYOgLzS6w64muP#DDMe=UjV3qv&`pca~p_XD1%eY7dXI_VmQ&Z^xU^vY5hN3xqaEj;u)fF~ecX zmf0-|ssN8o^mFCN)%XVZ&evqy=Sif}sTaOd_Mphn6iKEkd_E(Y4;5+y%BDLMt<3tHHJ zS)O`TfCEnrn(N!I{wDl5rJ?{bv^1%i-hVe&450Mng4`3Tlj=ilC zmOSl-RuHVYbj=G4fuv|L(QTkn6ip_?oa3U>!lE(O#wgq~C6tGc9^qxvF%^6@H{ z{FOiA;0L5EU}H1`RSA5+nfG-w2^2zVzcG_6IM(g%-9-m^7Y^!G7?GS~SlwnPG+YE< z%Gc@vYji-Bl`JYAb&49+UjI$=x(x1&g^DipGYk^+kE@r%(Zfdwbr1EUILQZ3g7>#j z{m?rYd-z)W02N=Ux~jkEdC`AA2!ZHIimZb|%GK`fqpy(XG$kVIQo?5qo;r!00lbX+luI+;wDLD!YNh}CkI7)pfD#HzNH=5^8+gS7S823D)Bt-l# zWsdHIb*^chO2F#_AHM6ynDXc!j^Yp^Jyx$hYDC9j-4PQrCVJ<=a>UVfCz9hdYO+$P zEVzFH;d;J|leolv;GfX$G=1lHIL%rqm#0^M&hi_*T`x!aaA|oB?MzVQhetctR%MNn zbcl2Muf9DCloWD}TxEp=PC`hU1wkW}W5A+qEA56iEB?kf^q$TRpcDfyd z&kE9x_uKHIV;)lE8_Z#p6W6u}U(ot?Oe6g)pvDxjdUVy^;6@D##`8Tf!&tX$oL|+- z_BJM9hlQcYYhy1E$)a(Le z?pJsQ_Kk98097sqDuxxM0dQvDoIZN#>Swt4Bh@pJL`{}!iO8&6sh%OQ_WrOxKcY7u zOZBxsB<-zw$Tj08g?9qXOYfZ|2q%@^35E^mG#0BzOCQ2pxXBpW5*dx-Zt19#ia)qZ zm!iH<(4RyIfj@oy>Q~_p`V}SZ*Cb!k9}M}TKuZ<;tUHkLg(ZDgpi`1VjxPC}#(%WE zl(+jb0l$q(gHA}2I|KNQ)#E;j$Pq+3dhJ&@X8}9H$Uzo53Qgcyhz75D4tMsY5qhKh zwR~Rl>;J}=2*Ssa6RR69S~?60S5X3bxvU42Fke>0p~mxVErxu@eSD0MqXb$v%Q5Ra z5NCt=NK&1qLeRjZ{uJN1^~Ds^WwX zX806)7wD`N^bd6TLyg}Q+x=O8h~1q}_D5gy*lrYnNF~~~-;h99@kvuMClnSv#iCL| zGR6KkdQu4`>`UA@1Qr>at+T_5vM1=z_T$daH8&Z6Xm>yMnrMG27wVSUw#zS3P24QX zlnGOM9zSpre1-4N_q@DUWBSmI3Gn3r$GUE(3prGk*xh=E3E_>v(hBiXJ3-4<2Vy{wU83_`a(%tRTnTm>-N+AG#AK10}#g>{U3axJ_$m4JaXArSy zsRGzvC?u)ALJA?-O3vrYHGb6g!awgFRaRfk$HAA7yZ9;4lLRQ=CCJ6Q1~!Pccdw9$ zPr3M0(B}vdaE!xYS1=ej?E@qzBCcsN{%VxwbZa0;NBJ8xnX2r$-A*zocgOvAEHS?WUiTkKpDrJ9p0D-bXMrBG95B5CzOk~0B@w#tcMD)8(e^dc#>IE(|uuPp+1x3M~qPnqu|V;-{_1+M>=~w&fD8L5{-8L z#-TIE+c@*Pn2s2aQhKWa5mnbT-J-ns*0aQsMYfIXPkkG=4&l%jdBlXEVD2!7XO1WV zNzhZQZ+)ZR_~&ok;7OlbL+KntP`bVATh4B{$Ww2-AQ2R{R`KpM$U!94U5;LGWnyTx z7kflc0N_Lq%+`II@ONzVHeJ>CBO5;IG8k7|c@yh??v;c`Zr$tExn}&-MIQHXdjS-q z!d;YaUf*S_c@pI(BBo*%k_}DoW_^}5&!|BR86GSjZ~DA%eA zEt+cR%>pgP%Skev5~K+|k2s@_9O5kJdB)@@Y@mlY4*%-dGAI+I-jdQmE=K6btM{wO zskzwsb=S|6L|vx^9QW(CD8XUf!PbR{7UEyMz+(C0r-f~01qF2|_nC4a+-G(pNW5pP zx|t9c++W)Vzv%k;=0k1NaB|*Xq~R2B=Y4JCO-f9=_!W57T%TWo{Z%|WqVB+HF81cC zAUia@++N^*XK!z6iJBZFmT%vW|zZ zUmz#U!m&H=JSMiTciXqj{a*BLcAZ7|iY3QDDKshX+EMgL5d~68sPjfAR6(!zJ|o1pXey&O{)UZ-z{C6!Z1Q zjyqr%g9rd20dox$5&!9((UG;s+;J*$op_&tYqZ6;wCC$zXxl@KWREDFgBq$?2K`cV z>z3wL>)3~=4;za#Oa^12TNg!a%KS#F#-}+b81t9oy>pti;R<7Y$sKpj(N8T9Fv9u| zBO86+l-p-@^cm@E2jFGD8U1xH;b_BA3R|L>08QT&7YwLlyuc$0bfaq&?(X%>2d#)6 zVNU2#VKR)P`#rm#nrLm}>+xy=VDUX_U-O4nUrm?Lx21~6CK2&y&+`GshnYm7YmH}g zrKrI6w`#H2+OIZGRtc>FPSVwv@?Ai#!q1HR9j4nOOADKBfib>4+`RjB&8?R-x8BjZ zr8XsmL-Dq^meT{L@&uEHoqZ{acVwBT%5VwB>D0^2#_bEXh<(>#We+q_nW z0f6j`>t5?Z+91#pX`dh^2)C0D;PUQ z{Bub3>W%Og^jY*`ar;ov%knJ;Vh{7lRhPQs z=u7P9koQB78HW)Eb&{lHNI)R2RI?2B2rLj2PBOh4&Q&ZxCsvD|jJW1mxn072FWh@e zk}|9o=p8lA{#^@&kD4;#JtCouPu7Fozn>VL;D(ftrN=H$gqHXVdI#?S)MLYYgK4?> z`huoa0&tJt6ER1^c_l6xN5eT;iUbWw30RjiZhbs6G9>p&h0NXj1zz+W|FMC8O;L00 zyCxseMeK^8L{JGR?+KHxDqbdQw<}K9E+aDD_#xFIzO3fHHL7CkeO}lzrCF9XwP(#3 zTV=0Pglyj4o2!g>sQ2wQf+(Hajv&T#(^`k&^}aRxpa`v&1#6eNjhkw-(XF=F_4mI{ zG<9`be`!0=hONAi9SEj{jar9F))%k@{lP|UBM%?+0gs-n?MJW;ZsAUB)PJ_9(P$g4 zPH>XHv@dShtV!KKnMMpGt`ksA%t-ofuR-oyTjn&|fP}FPu^vG7t$9*E@cAHBQ1}K# zeS=VAPgpx)|KtVjxFr;`uq|j87l#m0<-#At2m^72B(NMwtdty3As%#8hFm58311!s z}`fDW5q1|)YhQ<+}`%LfPtu3w>mUe2a;on$wPWEE=!b}#S~i1U7sDSI z$Lv!Q$p930n4?&7^fe`sKt`IJ&=B+y1Gxad@$p>n5{d@{bPbOfV4?4y-FG`e%5MK} zuhRSvz=3QI1@h_Tbe@euJNzxSyl-OeK2^PMZsNaH;;HX#l}-Ks#_tc6`+i*iL|^$6 zd{>`lIhwP?6TNmsc>JTV$TwT!agtxIMK|}PQa!2v^^4l|GF+7E+4~DxCu;qXP0~Qi zI$&EP`y&jJ9mK*Q#F8*eF&f~-wW*iwU)+D$?I;WDu%{+FA6>jUa@+@;djvtaLWG4z8Gu9!ZRXs?-?}j2rl93GjE$x9Ht}Re^kde`zGKDOu?L|0UCkJ_a0v*THBnd z-uv>_?cDtdJ`q>3aY0U3^Jzk~MTDO>_r1K)=EQ;=+4SneIodiN;|_v9f9na@9C|4C zQ|ejae*OEJ`|gkLCdis%7$w0l@gz0;TrRh;>Oc`0H(s337oiuY3A@RawLDFs$|Cv5 z6y86`JB1!Y2J;-_Q*41IY-5R*EY$sCM>o?CSk?i^V9G9%NU?BdCW)FaX?%fgw)IN^ zr>Or;Q?5imlTY3o6N8WzwE|Dqfs5FqH!$u(RHkF#By>UD`APAy-Wy=NeHXy9AhB6|%sT|coy zLg90Br~6zT80!I!J1>GrL2d;8VbgXH^Bz->TnalSPB}^Xe*^RnQU3FARd)7Gm-$|u z4)t|nn<_)Q$#pqol9i`(T+!wWTpw}P<_tGLyOk8@c|Pr%kaCOH=Jqjg?=e3;`bBh$ z;`vGOf;E_cBHZ_R=<;jwO5mf)DLyGMcTOpr6*g8l74QAx)sN<{xjcaVQ#7k+#+*_< z8eqOs_VV&4uWtPo(aHp7ZwJ@(HRO=4`mG4kAvxuQYw(Q^FAf^H#&-h`nJ}b-*C`JL zPFd^$zkg!=FHBqf^CjkG=?p74^l`A#c_egyph|$ zz&$&h7!Rv#0zZrAdikbc#y|G9$Z9z8^|pnn99?Y-pj?&#uw%} zrEsrtzp;3jlgADu?w5O~l*`58kJT~$=^eK8N~%;^b@fVW0vG|Y0mxDA7I|FoHk;}c z^`*xh5vu4aS)zw2#9>K!NnT^p6|gCoV6q^VBv^Bnkx&4a>t*cLAX0lhjIv<`=@k|vYZ!kP@t32Q z&3-TLZycGQzm#|9`T6hjb>(e`xBtKi-HIR;eTxMX-r${#aO}nEuR6nT1;lSPuVIl|oHgwh>qE+OYXuD$AHWqW6@W8VlBxRHIcnVsEh>x8)FS>D z{@5R>Nhm4ymv*@ES@XBdr>z?z_n7mc`#)qo^eOX9XxHtL8}M9ij(;x49&LP<%fK?* zZU+Km^YbruEG#U|&o3=J$hWEeqF%u^_5nx{Lp0Jw4Njz6$&(XRME%0LjubE%HIa%G zl7U?eerZ})1EsE`-#yw@3aEOpv~_1@Fp(I{?A%&v{4|nI=hMKi-<|47r(lx}^ljdn zy6m#l*3EqZT?zDdmZqmmoxK6&)~-Yo4ue$3{|@8bPi5=k|^; zLTcuE(us0J3m6mKGwI|+*9;(a;-l$6eoLn%WJNdsD8#7}VQ_9kl*(n0c9818C2*MH*#bfr8kaE_+S-2!6p-TXN4H$XWL@Z}wC zONRFcA}7#jo$2xlQ9Rt_#SRWQ$U>anR{8P{zNGtj6?FvPC7;t5p#eebNdU`1cBvai zCCTU{f@4jV0*Zhq2m)9J;?*2b2&u7z0XJRVVFMX{=Wy2`a8Y6*yI!xej_GoaT9YeJ zhiq0xlE}~|!0UJAG6TJ)9ip*DIrd%93o`KM2t+6dKV<55=_$z|5~LBSHY$g@@#fhJ z5hrj1QW*WZcJI9!Ob~MFhJX`!`!=I&Y)c)vJ!Mtnrz1Xu?G*iI-WG`a0MH%}yzPv? zD92tO4!{1bmj!)^GmWS1soPUD-ibyo^E|`?yrG*38EONnvX;J7+w+*wIhfPv6ZBNc z8y(p z%1U^&h*9K*{ZI`GkO#$5$j`x;EF*hU@M&~L+_U-94ZDrH1iu{1otPKFxBCMPkz&H@NW<9-8ob_4;DHPVI#L z&hy#Nq9-n=8l(v9_n9mJmb=Xt5k0L^=MCZ-)I;1wj1G^SRD-Kp{&_ zbld=n`JZ{hK60C4rZ>M!G=K}aq>isYN|MiHK_~^k!t*XBO@9zCo<9l(8+q%z?V#UG zE?@L{bB3dShsJ{FRvJt5^Q-s&L2->HlY6?kks|yl* z8XRI+4NeW^S>wGC78>8a_spuY18<$H$Kw-MP3hRfB^YWLrxdSf;A|el> zohlVw@%lnG_m3VqpfF48zo%aV%8EOAFC2ThI5%uMawsG_CT#5Bss9do_hs03p#8y= zg&!uKN|kX)Iq|lMy9+!m(q<@ek$8O;UdKRRR@D)Q8j1zw9$!3zp(+PsAq1f6Y8L!_ zyeC(<7Q(FIbeXKu-!snU=f=uAj?VO0j$^*b35T6GnT})i%pBcO9^cYa)s0BqCo6s( zRJ&m5Fa~m(44lBvgA<3L7oer6Edz8fL_TUrgGX#JY+^9bAYlUyD-~UPyl1Q1%%JSw z0i);e>vxy-c6R6I2OV=H*;&}M6{uUMJ9S?dO-}*I_Jh5%+gMEws?(e0#K|qzrC0ev zZ+pqJL-*{_4Y}{szHH+mJ}~*>OYx7P4_h>G6f$)UTw`m;ar7o6xi%9PsVyE!LH$OL zm&Q>wou~`dUaW%KRwqNCjCuSa)tasnsxFjT&?lO6RjGq&q;vDuvTNj8wL1^O2U@8xpytuN6nJie@e=JR@EE*@(Q0_tW;Z2=X)ltKFvo`(@A zFI~yTe6ma1D~GIE6_Z(=Mf&_7bN(Ww`ukI?*Y*d4G)Ds=znyOK7cK*^KKvHxe;u#| zw&Cm$^rG8YWE6?Xkd42N6sPlm)+tZam7t-ns76q!Pu#A3Omoq6USS`veGCag+Q(|g z?N_iRFiZT?ntY77Wga|iFJd8X!^jde%^N#S>8&Z}#S@S=dtiN^5TZytnjpgy7fWE_ zlE6AJNBD+!QKGc=D=TZox(64z(Dz6dStpX6l_YPY-nK0cM%NvQdlxsqg<`GiM8C_p zWSDQiIltb&_9vx#)*TsH_bv3H^y1GEd-{32s~#Z(K=|hrgmhr=R9b;*fKqV10Lv1w z2k%g|n>1l|RVfwMHQnwV+!n-Wg#x^7u-C?sc&d~f=uM@12fF=K8r#dtUgCom_OVbp>-e?_70so5QmZbFQaZT&Sg+AF@;qwK)r2Uf<=CNbuM02b0eVfHmBDC;OG|FtJ zVBUw+7ENtD%2)fjV6YX3jYy3BKLy8T(Wt@a1N={a!=7gEN4!>n*TkKLCHw%_v+&Z2 zB^bdeJ9dQ4NpW;Q7FlO>5FzJK`*^od+%@?lri#2Xr+5h~?9V}ZM?`*Ys>t4&OsTQl znR@+9E(UN@>KA@%=jiCanNf=YZy@MlJzh;^*3{FoE;xEac_RX3oY^-?~_nzna1`3n7=BtCfN!6Hq>CC{u4DNLae;s$K$=<=j z~^ic=0=2X(>jUQ?snuR_xE+8MqL`|`1>dIe4?JnFCGRs{QUwz4%460>-l+hZvW)-oqbE>rCRFi ze139&GLg?GlK7{;gUkN(z64JsIb7ICz$QD~ZWrG1VviWp7~7ma`%%r;V?eO!fS(ZD z4_o4PjSup1aT&L6eZ8DXt7F|L1GoZc?WKC9(~PlQ+-mX>J|1Vey$l6Nx}0p8IM3lq zC6^4OqP$(rI#CX?)++F)z|K)?MNP(QAPe~68kL7>R**tIDDjX4t)m(b8zLBW_b?0A zs<47la~>&{kKHLrK+J8`i8?rU{Men@J|`2+_`jx%=2Njw!;EHFI-3!#8x7of%nmgE zPkV)^*{ydT+ccg@X3G8#`u+LQK+KnkN=gUAL*!uBo9AO6xT!n_i`rb881aEC_>~yP zog`J5P)4bO6$S~o9|||YU^AznniqCSnUIcaS|mP{O_p?3QZ009YT<-b=m@9vr#&cT{ z?PKYo_?9?Q*i|G*M*9N_63Is6@n~qSlMS}DI1#Vc;|cds9-IqBohgC-s184j z=~r=^5HbVI;MO|VWtRCIC8VT|K5&i=Fl_-n$^CF_c@0wSj>g{;M(xRUD71}}0)LkL zy-y+8m*!KFA|kxmr13Wi`Y&E|0=S<12xuA)k$?|v9`eof;W^dOcHLzK!uI`a)&?ZN zU6<&Nx|7|{VORgvr~g>feFc>TtZySw0Chfs_P4D7(|UZm_G8_LpVZKX&q~`-ub~VI zT!CeIClG!KQ-;TPf!gw1;9^O3O-^VCd1pXjxoR6v$Tb-r=W2+XZ0Mq@%wfJs?yTXs z*4&d2nPCM?%N{W`K>CKI(?S4ISP;mcBbG`B<=EV*Esggtf+-Vy5?3stS{#>E6f=U< zSPgLm7Jz9jlf4<_4ym9Lzyw-H8&?I{iD2U!tOLMv^hI3M$k8%#GaZ}fo8tXSb@YE9 zMMg$I4+!`P)6S(A7tbA)XQuc)o||54)6;!%_yfv z5}|MkkaJqP80&1D=bQX~16lU!&c4oAF^zzX!c5O%&x{>y{B@LdM3t0((Vv=;95`b&xhI2~kq2Qf$@Iv8 z0Z^UqYx!s;8AdH#?@6b^$x1Y@WyW*GVs1P=KJJ7H3qsXv|rir36dTKW_=7ig>U|(3~qeUIr%& zkC`W(AEmlD{UJU;7WNt~xQ$)nc6J1Vz)nDH0y;R}556)26M{wyh<$U~1boM2NQfmc z!A&)r#jkc*hxR&2s9Nh;M3(~_iOw?X-KziJwG_8@=~CtT{Yulk&-`K>)jlf z@Y|_Uj71}rc(Q6IQOee|yKnf250KkPYGi5>cO1UK3ES9EaR>+DzFsaRoJlnm;5C&L z_{u*my&k^%fsWH1ujsg^ z9RvUKOA<j zq$mAA!hnJW7zPKKwDb-|&53?Q`w$&Ey`B=a$nqLWq_okWTGb;;N;@Tj8H&bwsn0E^ zjB>T(38^C-Pjd3K!k5W-SZPtGN83o!#E(#8ndlUtliP^XyHr`$QP`rf(pZ5pO$XK{ z8&YlccL7A6%SK09L~A6P#UafRMC;Wpa<$L{^5wj(MYX<*`}uL-M;^i5{J86*vh4Pc z1Y6r6=)ko=Ie9IoWf-gANTmWvJ@CT7q(-&;Fx83QRxxHVOhL!BL~IRJ|F85V`zb_T z08%E)#dUBB)HMw`i1C8fejVS z=!{jgeB`Xct{J4AJJDzNdod03UYvug zTP^}WxdKb(e#$V&iZhdQ;c* zrfBPr#EPA|AWczFUf?kjar`cA`6rL8_}QkW$OEQyqd6E1&LzSr{0ld?y{(^JjJ7yh zZh{L!L}RYy)TQTTYh)tOT9WAlptEP!jiRcjCsRM+$;q<>$vE#u$HUysM;+3Ia7w6O zt?HKOW=L3wgN6!A3mRYX&CrnIuxv04bwuDU(^4YmX?JS8r>g|atHuffL_7QR-ifFK zdq6k5a)`Ae6vW1Ne44h&#vOKA3<{dEnL-AE1lt@{R5Li29?)N%ACcveY$}vkVDprD zw>;6C0p5lbvR5AxIEpeT3|Xb$ejx zW{fZ#;e!J-Ai|*?Fy#=%NoiFi4ug2$k;xt31}#}T$AA1hMf*2aXn7bu+$ru%Jt06N z2|_Y|0bI%R)E(#W)g}5W%28*?5S8Ta)y91SH4^BN7X+3Bt&^KJpa?t$C6Xoaa=dEc zu2H8X07q&GXm4Z>f)Ev7?QXkv@98sd?Kz=F?ccoN!#jR=X4{#!8gELg(N!+D1AE?j zX2+(#v!m+lkz2B_&b{?acmAeno7{S}JbEyJ_)y4a(j*DEZ1E~dQ+rcDg4($g_)Mu@RgT6!3O1>^r`M{fBf1;p7CqFS6RR(>&UcON{@u1H)sBnFkupen| zpof}asa^;x#j@c2!ifj_Q*j;)B*udC`6vWc8r6grOtA3Ou;~h5)4xSMdK<;drn+4MgSrV%|6g~5zOKG9J$-t4P+OfE)Ta=u50UTEbI|H+AnNsSK-Ww20iWDbfKQQiCN}5>~A@G8<`aKD+Tv+;X2ae9!=zq7(Hkyk_PXQag3rCQs*&Df z=Niia{V(+ZU$DvrAM^N{?xIKTjd))S=#>jToy28=Ma*44bO@S5;9tbUxXA`eJ!joS znel=_r^O7$Ys5Caea_Wzc428@VhANZSRJAJ#Wai;1iq&0L~yYKu?~tlTV6Pf>O3Zu z3pscAgif9w;0B~4IENV&aY&rgkqX}~V^YP^JpAN4&^nk&L46^0xi(y0P!>&{@Zy0$ z={PrmJC1V`DhU#{j^HqIH|`-Fu(>Cbt&7WRddi7FAvU z#<6c4Q<-9#_TDG=+NP;6#Q6W*jWtqXm$<3p*C2Jp5m42R_C3jI3mk5s-=rDy|A<^b z*O1VOi0rA&OeV`I{>MrhhL!+xotqZiy6h1q6q8vDdYIuq&A`B^ zr3_g%Qh)|zj2S(^&@Da8Yw%$6sQ1S@@X(V4-?a@O;9`Q7}*)47SyZ95duf~YN{0x-=O|~O8C}N3E6g~Klgwo-(r1Ynb~A*GpzkAi1w#9~t7Z1~i2`0m6i^kh>uUqSy;vf4F zV|__S$0lf{ZYT0}oQSgMRK7+JQeM;?5X)6!8x5uL*ZcyW<3zTqr;&j;0$UX-VuazE^ZTF{j!|4JXc?zq5zI_tG*p)oF();` zmyZ+RwA4HdvlOTrpDMM=oIEiiV@k}iuT1)~ngz%^Dl6!0s&r3443Nf#vmb+E1Ta)o z%MAB+Z|x1CJf@EyIzK!nF~2d9tIPnc1^;0s)Uv+GL+q*9L!AWEYgvY_C?l7ay2s1Y zXUplv4?oT|q2RL$jE1pTI1mU&V>*mE-tod;l+R9=tKG!|BR&9GTef2ZF!s=_Pw+J1 zza$JCs^pG$+UHRuGJ#^g*Dw)Uht!fA%h`I9E@O+JMkmCpJmwR&I$qpl+nYF&es3?$ zgexpdY*^w1z34=tnCS3b2L7IUWEkd|?=~GJLb2A>Sj0)=jVz?Q_dz)6}K`h{`G#Yu;v>+*~8~c?}co{1BROy)0iZ z8pP)K{N{_sv+Ekw>azk6J+^}gd9#%hMrw($8el$vV)99`zTf)jL9)2AhD7%gbP<6%J71(_i|K6ClVk&(-1#?z1^ z`W`6y{MD|mG1DwQ(0FS8#LS>+*5?ad^{&F;;Ye8aSEhcBIzD5UjP>?nk?QK4J1wW; z@sz|)KMC#LG{Isu11=$-lSlMB>C4MpCl$I}i>5<r3}cOzfEuq<*8TnqOPuNv?0k#S?qD4(;WxLE-n} z-C+~LEP*IX3bU)oo>miq^I;=$qUhnMrUD6dUN4@A7{or>Y!-yXcljE52r7ve=Yz*= z4lpF>lPRVZsuF-Pri!rg4Lb{hV=@EEizFQ@ch8O<^yM#MD39Q?bj#7Y`t!iW27T1>{au=aV?Il=*(I9-fsvXM z)W^C{Y=YZ{?zFH#_i%h{(~0geJ?PX%0)abkmcqUO>fF*6%i^xvn^_4~RKpUo7J z^<&~>Sh~6G-8wwBtwR|BkAOnZ!^;?YhAHmLz>|6}6{R+YqZ6rIAgBT0vMZ(~*{=4u zqubKQ#t+>VMdsK@4?Gl8U3>aQlG&K`#r~{0)gEhfynpPrLxri~J;`Fv2+EI>{`fz* z4UpGy)`scE!8i%q9`Y>4-49D1UrzkE>B z0erL{;Uvqq9SWpkx%hbRp{!|U51~9lE|v-$s^UT&7t%^QK$;@WbMN=_(9VSh+iC&q z^#RK0fr^Btke_kmHvG;Nl`9n0_fbVjC;_Fba=|A29xfd8sc*tn3Dmd;r(4qoM0Bs| zg$pUC8@J?%ja1PKHs{-1ylSbQH2;6WJ6U6J-MAIR6qacSVsXJAKaSzf$GoV|?!5=( z1Fq=-vLmdn`Gtuls2Vsf*?h;RF5Hei{5ST{Z#6eB+NRZ)(zn*Uqn+zo=)EJzpqvU~ z2%~*elp7R+CQ{QtJ-Km1#cSK#RTwLES2VyXhiusw^TR8JELJUu3b7X&)c?X?;md-B zOkuLr1-HK)Oa`qm$_4W7#rtg9pb6=Ht#&Kg7hoP8SWTC`iT&Qyvws!c_2%@s_F0c{ z1d+9EZuNtgtbMOO&;04G5id(qzeuUJ8+Q}4E|jc#q3-c{NvgjK5IEZ;JV^W)aISs~0UyRT z*okmV>mx)xj7m(N!V+m_+<;9RKu0NKqV`5Ja20{aLa+~#b?wk?V~uy}NGi|~%Cv$| zB0gc;HWxEdIs^#l8}`?4JG8f!gDnmp>R%y4)nOYXYyim2ER>(D3hABW5)6W}gQQa0 z*(7x4c8^aL*M`_iWARwV7xj(DPtvdK;@b#Ky^ftDk4z)S{W9kpbvEBvUaT3$HAAp* z(z@(hn#Toj*$5p4mn=3Gd7IprR?5|@qfIWD6YntCcZ6?^pdFA%@5y$}KBnW!WGRMP zFiZ!A~Xzq@`*mP1@dSeBnN zBM!XyVf!jzw4^{TJz`kVQroubbEldfNa{LZtq$cl-|QA>07clEz5F7pXC8 zwkY&ZX+DaZzemZ*unnmNS3!jEUBc9(k5Cl2Ky#sFKltZJ&Ea3dU3i=h1{NeH_VVqc z={OI`^4xxIyj48iSfm~ax~YEtjFNKXO~v9S=`cTz&0j7hQ1ldiQ+85H#xF7l`|6{4 z$R1zg_tGdU5Z8%i!YC*6qg*CLUZCSErdcjm=lePS&pXvj8(efxvUJhsAeGR22cC=_ zK%NJ3f;~Cg%WFoz@XqI5!xaO-1#@e+TU6vj@3`rvcii;#{OD+2s7+o^xyfpczr*R9 z-tid(p;zcZz&;7nCP%+daTBfIkHfnnWCgBYM8<1<5K`kc{(FT)Qy9lpytkHZ= za@Qr}5quZzYWX+=;4g^2@0(0;!x4Uwm>{Z%L4NZ@O95;Y@*KBF{pDxck@^%FH7>5IQXQJP^W*ZuCv|YR$4P(-O`D|xTdD=e?vSw1h z`Cj}Xul@T4SjH%4y^LA!LEtn&ab)P3HIPjhl4^+GfmwxO*4S(l_U79j5>Cw*41}e6 zePXA2dUL|pc=DQ_{>yuIv70@xn-@Izo5uIRC#4-HCPq&ibLM-lEgpA@6!^;LatG)l z`C(~xdeoZ7^NK_=>Nfz5kbxGIyKTvyP8SBUjR&&>g>=_I)*nX+ImGOnN_kpUJNx4P z?7$&kxidR3knJq{;)U!$q^q-!QUN4NFzf5=iVS2S29R9v6WCXuK|6AUF~}nuLI22k z03$bPKRoS{RuI17GobPjfb)ZBgP!?NS90)!9~?|}^^R%!fEh}3juj9(RT%3`gv+7|K!}dd*J)^h&9-yh`?QfrW!tt&WJIfr zb^-s5{XN>1K?F4Az5`5TL?-9Nsz${r2r!?O*k9OcZ2Y?2yRRFMp_UcZmEJe02Gz-Z zU0r(MTbmE%eU10|@`u`lVf6FTMKalOv)~pi4&M*SbzzQK?)3^^auV zWrS=m!zuxrGa^4ZBZa99l;0~^`3u&4)e`%0Fq~-YLR4OtSuFx4Nl{DFv{n^&trllW zv`=e986rZi@ziTy>kADcrQ*pz)SM`)II5Ip__tJVU7UutM&Ft)sS2$_uNCsT#6~Ku&`2iRT#74D>I;OI}PifIN8G+ZEhW1Q&s!=_6!zjC!eLTD+@qR0!eLtqZ z@WQfQFEYd?|0?;wKSm7-@1T zL5jJ-WIy{4D;|3;7PoG+2CTy*{cu@mnV-EU+}9WWWH{XTp?etTXiN%OhUP4JvR6f~ zV{QswNX(aLF-!w4<|XmamK3q!mS;C;-Ni!&@O-_f(+d)P{y|zkxB$+veN=kAL|IDt z1#$3v+Xb9&iNl}enjjOv7%V3^nppC0lYf=pZq+G%L51!p7*wuto+}u`d`W%5C~lPf zFZ2bZW{y2VE>ci2>i8{~C5S1ZsA$j- zmpMdU-hcnbhi~^e$~63khu%%?diNoC6{nE{bnxH>jthCo{>t&JNdSCcuR^z?qk(tWE%n|dtipvi#JH9QK+x9vfx}yF& zI9vB<219>T$;<4TfT6~Sh!wY6_=|?B`v0=_9sqKk<+<>Dr?;tRdfDkS+uQ8ytfZB8 zw5nFI)>fC>*cjWmVA+6WQ?3_Gi~$=Ik{AaP?gm0h2tfgd8qAs=2tm1&tU#g04A2BTiKiUc}KX*7u+J zv+bdnUF7|-zuruh>-+g}Ib{bLH&l|TGE_Z@lE_;7>qm~B*&a$J0@OTU+{>xWz=qjh zU!d~^L}Zk|as-hd>CJ#mPbVmAGxtdln$W*eY+A(g^?C`YJq+7ry~o187Yju$W#>srqdStj)@; z`o+f4nog+sgP0>{;el^!zZ(c%Fp$>QVHC)T7wv{q z6uRGlwcNO0kS{V3oC-L!_QzEIY>b)VfPfH?HPe9jlofaWI1CJBVlGy1&)B zJlz=omx~VN_pTeUzossn{Hi_UvbQeB*ABg&x}eq8j<7Xtc41vQNs#QxE8VA_(8dc;IXoYX9v5(b1z?g*62}et~zyG86C$)=a}RRT6M5ybJgVkt$XB) zKhxiC(iVbb6S^esbk7gZVl`6r{AeEk_~8W?ApfoX30ub7mg#K_?`)sNT|5G{&hOJE zOs98nZOaTI!(V@b!oyEofU{IIfXI-(kxY8;7`+?bew+Y#OrsC{N20M?kZyK zJ}TUYaV#U}`y_a$L%@o84s7d?H7nEP>B^NQa9+#7^1uo{lUqLhuiQU?f6 z)L}z~QP_&fj}7)ZMB(5!Sm*l?O$MJo!mOZE#HdY2zog|tEO$iOUGXMWv3PQyl9NKA z8(&l^B?GxcUlbuy*Pnd3Xy?)b3k`ck_CG@*DW@L3cKe}eH78{vZ+^hJPLHGx?>0v7 zD~SIrGUpd8IQ)enGaMfrj8B@y94qatygyPkHYrk!Npb|B#Y2IVVh530SM@7dqY`;^ zU~k^s-Wqs+q)$w|$GI*C@yvPua5Wtp>_4R7R0bd>5(s3eobpjD4WV6vVrz^n(y!3Jp-s^g&C>U1LpyLJbK@8PdJjDLUimOG&5 zK7CLe9}oEb$?CS?o8sQ_;(_A$@p18Bpg-w97~EDZJABYG|4sXA_{AM>Im8kHW5?Kz z$lj=SxiVUKM4AtU<||`6j6i}VBQMdS$-=1eSce&+b$FS_i;{kZFgG?)Ol$~61awLj zF_V-p6_wyBS6<>yNuj)SlqbYpqmqyUQKur&S=cWr50a_A0HhsW~Cs5Ui)8s{OJ_sWq+L1AtKb)f=xWvA8Hn2nC87kf?3 zWR-?S1fUYM{JMS!TpCp&h4RoeLd!=Bb#NBh*DDK-1bD%Ap_1g+ikXs*jMzcFlyPK_ z&nw6w*Y#_SISn?s{ov^%j<|8*_Q>gj+s7s{0CYv*$JW~HT*K#U%+1!ek|x+tGZTNJ zDII55mnEh#;KTqNN8YL%J-2(#xT~2XTPrZ9yAf4AE(PTJM~;l}V1e^vN5xHXp{{dI zkF!EF25lA?HoPSo>$p00v<6Xk&UDT@!W(oKqwcF=8lCGBZ({gj$=uA zx<}go!CgD<;(;eTe8hK*AHRZcnddioseZwC9TmnO@DIF#Z;v@2j@ajrri-@-iR_89KBFaRw)r@NSE zfGxwujrOS5;4$3Ubk#ic(5i2c6VK2QK-pp`-7NR%yRY1>_m!LJ68+w1i2a$S^CS-z{t_P75?D$JzJtdoZh-|SAJf0j^5fC`<*?; z_-|RC_j6jo$)mmUtg7|||IzR9?Nz@?Eqm+OgHYS48#MjDW!*Y&a_ac(YFFg#a+=rf zZO^`TQ~Za|xF3J}x;k~_UwxMS=+x2a$KtcEsar?xn>7ml<`tgTc)srW4z!LPoh!l{ z6cs7GXxpLx+&|56QiBzjo`goe=&I$0zyWec*)be+*WIoU`MPCI=%YV=0;#X%wc|HooohAQ&I8Y; zfERpufzz}p{_+c6B+1XbDV!H^dcJ~jwger}X86+X>ltITj`eEwNx%OzV=Ypvb`*6T zHpA)5Nn!rVd0{8tPV&Blje+;&ZR6CJZ#no?Cq8}F=?TC5*(-3y)ZHG4akwCKGvQVzFl(rd&})%6&pO-Ht5+@zV|&h+ZIjPeX8o1{fM2 zEZ3OPlZ_^EVaxEv01y*Dpszm#DZ8Bdc1VbX#saz?7z;&&&`|)7->$oGER0 znIzzDibNB*IsKMU==ZsV@Bk@d9B-yCXu2@11d~B!TF^Ck32M8c;eL#JyI2;P{rz5< zTwl)B>jBRA|2=+6GcC-ZmuatfwKoR>Z`QnKBw_}ZsG&*{Lar2rd^uLfD&+`00@RZi zf`{hFVw?oRVW?1bPszyPlmE%oh(4sEY*Ialb5 zZwiMu#rvG^^LT|Sq4-eTdDJIVxhSHH3Wy*_K$e6jc?+G&aXFkVfWHIg>pQYko`>65E2@c zEKwJDez(xYZlIf-^}=l@%4q<5?K5esjx|vwxNp}Ry(6_f!F zXI5P&S@8#LMLF+sF+@lwO||Qj#eJsX$bru8_Bd#deKFL?_W6!O z04KTQoPy)<#}`ABybJT?G)<7xok!^f?Y~{cnEV|38T%*@CdZLY)I-`kj6i^Vakmi) zxd3-EN6Rxx40llq`QnTiksUyRb&r9Tp*m#K@tA6`38pl)s1%wpWv!`O6IZBVpL)fW z;bbKlDJTt9CRdMoz!pAhkIrSc2-ea0&e8$fyg*?pQs{M_m(qS4f9l9j)|&W zNLDhDiS7IA6MGeQ1}(vKeT3HmKi0g~jlbJj4;C@DC{Ge$HO*tjxZ1K<4^AJ?@KI15 zhHI5A;T&QW2=1i9b-V%zyrae*z558l{m&Z}0|D-{Mzb;6eo@9=$kgkd3kmjS2`c!2wn*BlCBmO4wegaB zh2JH{WEq^~BXFsPU$sU6TJ7Z(yuyB&bEeUqo?JS9Sn;+%3wI-IPWOU8d^5(&y5%qi zrv*-W++diF4;qneUOQ(g#f(PORZqVZ*Wo9w;qyAry08rRF-Sk8S!$8>-|xY4M%`TR zsQc1s?!>myjy1ZEon4E`NUXEtbamDNZFIKnC)QtIT6MyDaHCHs&PDdV)#vW+!G6^G zdtTt+MepU_=>)#I9CbE`!JI52!_-Gjgt0x?E1{yzJ0pB zizn$D#Sxyf0`Su$OiIe{7=Zjhx(^UVEZsyx$a@`*cF@%k9iL`j1p0*a;8j;WAc|*B zspj^RiwfqCv1F>J&WPdzS6%fWvfOGvW;tc?q-w1231fBT#Sv%&cfs~@6+GNt1ncKR zo`&6^5$M z=;!Bx$3w1ar)jLsV11bz!{jxCd%d-i3D zw?zm4w91=3W3G)Y!zc|2_-a6h9Ra*f4(V9`*Tq<|r{&RzmtH}Ampoxe%Z@#hhbk1H zd4bC>Rzi3_{T8()Tjk9&we1HqLH1{qO)UPK)R*Am?Hb@GCD;`?Ub=7hx5%P^dOPE#_0IMT=SP)ck!;ADd^fIP=`Tqbk zfBy{nd>e2@r@5jB8SO0h$DQ>omcAVe4F=i)yqZY0;a=cbB$g@l~Vj zr^t<=2DOq`W^xp0OXk=XrmH~MVOz#bz%)fA*;~?r-KTKjVh?>v@_UE+PL}(Iy#8Aw zN>kJiD3J+W+`+F;a#?M2&t0oYx4-=xe(k~{-HR_yL=^tbzA`>~fPd^&{PGzt?44b` zxmTjQZtRT;j2!ZtK>RW{PGGWQgu9Xk%@>c#WWL0+KzbR-pV4FsUj-MAEt zA52<`(MQc1NWJ;5Q;~mg)MNj^G9l>0TcLn29ljKB&uL#w(_+3f4lhMcjIU`yWJk0> zuGBPa3L`$uW=!-AE@u62{3QC&z&qHE3l>`n~pPgU7l0z&S7V25#kATDtJ-g z68yTBJVzWQ#1xo{Rux&hpK&97pEF8)vjC3CIlJ>2w}+!cFN{I@z_l8qP~87d>pB5@Q-i4-&=70vEBI*=m3F{fxde#QSYf zo~P3>tSQH?HL>mMBHh;%=F3kT-cJ4C`rZbyet9s0Yk8N@IYM`bweI!)nLnkQ+FDBiV)h z)!gMZX;Q-&T zu{^R7GY;vc$1aw`idKU?eWSc44y#2r9j?z+s|9$~ViHePBqD927&$SX(9-olDRLIA z0j+5s@d`AKF>cf0S!1fyH=0U~_LZiLORZooaV!{%wf`#?JXXwyg5}q-<=1hTHFI}* zH#aBT%$9+0fDylH=nIESQ@K(pH&qIU7t~TYR*2zWJ#}_*(fzIS({DCuZjj8i%ykUU z1;>9+m(zz<2{~+`zpasNd6iVbuMPMJur}lW4)4NeLuW&b3~7Wm-1CdHsYdV&r?b!p zx%AdiqAld;zXfjEbB{SI0+L7ne0sCz4|4|p{&SqODh`koDDPTezpt7?#QK1y?c*q zlW+!^W1|AcFk>D2F#DCL-!SvOnHzLbi!|f8xDhtG+h(Nwv+;4j%?2l(-BvDyJfx^z z{rR)A&)2;wJj4U>c;J4+_)FUVrD5Dp`}2!%P*5W!zMXXqMWjbuMqJ=Bw3|Co0}|jM z3y+cZu$x%C-cSqZQ7AZh2q)!l>8LmTLbXwCz#+Q|d`PvSHeyxwgjcq%+pqh)w*tmW zQ+DnIe61>gJaq@+~1+6v2qW2Lcrcq9-Q3D?I;Q$jv3{G6qf z`e7q3#_!Nf_2fy_)b5B!jKg&$#R4~ZgX-)pUhgflYS4Sr%lZ@AWTsZjOlpaKYkF`H zECt99<)UG)fDDwuNhbjhU^jVk7&JOXokBkb35eaJiALN=Xz2xQ7Fvvmdm6t~9qyaX zvol6Y3*%a;{hsoWEEm%7@}=`EyEHo@sY6@_UjaY%=kzVxNq^>u-vqKtEDWt4e64ur z@mKKCJ5(6i%@3|&Z?Rr@>GYx+V-DUzGujU|p7uMF+;1yrs4Ggmiq@7CS5}x-oFW ztf)$|C?=A7hLGP#E;mMY>f1xegM_c-b*wOYUJx`EVL1(n3nJ@oP*FnL)q2_THp;T9 z$wPZmaY|dH3R`a&2qu$TIyA1EH}4U&^G12|f?+k!eK7Lkco;DzkuWt_A7sv+#-3#p zkfG7`WVEbQsbBC1xHLF(Oib3N>fodxp*GNY=t;FYRfu3QC>BVj1)ef(xK$JO%}z46 z2Gkzz#}xJfK|s>E_D2R9jRCeh>89*snO~KW??#C0TQA+J#|5OE6U4F{K|UNI704R# zK*AQhj7d81WkNdo&4)g-aDi!bMMGE%W2RsykoznfNFh0q7?F4DQZ8u8HKi6|63`X0(z*72)=#P9k z->}ceLO%1~kqYJCP2Zv-EkF;spqy2t77kj_g@7cpD1$cwcESjDLee=)3ILuKqJaJhDtJOB6xpP6mK5N0 zuUvOBbQM042)NgV;2i?^LxosRZMidShs0mjQ;PKkZY8(ed_O}! znJC}Q$oTSN^bk^vQ-^@bjZGuPAT1%_@**@1xTjDZMk-u_?r9t#dYsYW2CEoYLyUb0 zKcvoabU1WGERmz`p`!?D)}c!(O|>kNnL~XA8>qsTW+nqhwyv2AnK7BPJ?0@vVV6Ix)YAA!@JcApRrKkJ!5VcQ8?@Q|zF0p)gP%*a06T24CfQHdO z*1tPC9WJSQ&|k~;)%#y}?5sVB+tMGJmHy)E@Mb=-4*!D`rvI_;dU?n=bB*e+dwufnNVn3JH-wB2z{mxO zBKVDO`J*OlXwF$1cDx=wCW!cthBMQ8M-?U;SUn6k{zrw#bCyna%HwFD(5dYIAiAeb z_5LAnO1=QM-c}Q=6a`8I*f|m{>H2W3+_B?yl#(SHy<#tGP{JNkIJKF(UO zaeAR;IYnNa@DsrJLd`LaVPT;qPaTgGrzWt_qDWZKG3`0~g{k29FFiAxNNY(B8o_Nr zJtQC6cjIu`zvM3uUw`E@7b=uW#n6&IUs7Y|Z@grFa%h~f@uA82J=Xq>! z2}1%@1?&m}jri)&x%$M0y?TI#-sn7Jg|YWS2BrzpM9M{HkCXNl7P9jy4X@8o6~W`2 z@NN5;g2yZ@%ZAU(zRC}s3m0!ag6f(drBCL^oB%KGfa$wK^}%x|$G7c&>N$a5j!(_; z?Kf5rb0RRS{_Cj^3$FrCn}Gk&6xw_pZlmN#m|!_byoBmV1y3i|u=sT+4S-FYURVq1 zct%vxxtZ)GHxA3{-Bi}%CM%^5PQ9W}4(XT{Mo>QR_LKgyZw<)fm5N!2MQF4u##d0y zYA%v2w3lOkcP1F0z87-<(fl2sA+l-uL1Q)ndmjE>o566xx5iargL5T%0$eBLhA)8A z9Ti{}n67oH9zv(MLj(MEiV@Ot;wDB5bjStewSz-Bn6N0TXEV3#wbj`0fKbrYV3-XI z$5h)Z;0|1H#8LvbFP3YzS7_ga$tzjOMaS=_yZ4Vrmyi%v3FZ5udVpmLAtb7>@f==w zeVAJ92Pnao*Z#PshtrWD zyBP$vMNhINa@tt+*&x674BB}T?YtNE@~dI9d!grjR6=SlG#uI6E`C&-rilb8sk3tb zR)^r&-0#uE?!XatRt@kmXs;eiN3_RUiB>xcK7EH|b3O zva=NNmOHmhX##WPbhFI&=v;E>I*ZR+9%-qgZ zugyz$4hU1EfBBg3a?F`DH2c?b8BldoIB#a)udw_PcSJa{#R8?kAQK8K*e*5fND+>C z94Vn|fd1kUvuN&O!6!h_hsOlCDpyw#V{aPXr5Aycj0~cQk`PJCnVR{}iDG{`3bmA+ z?q~3q)RGaQlweHGvA>h$mKtuxvOusF!6Bs-WvVD!3%2%Nb?Xq@nLt!qF@ZDz<+$nf z=KL&BOthEM{mC%vPpPU_i46g|HD1R0je)i}NBoL2=xSo0 z#4k=a7P;M9*wKDd=EN@GK@aUZK{K7Ak(+U7Tmq>k4nYMFxgu)OpbrX_9Ba0T9(*yK z-7zy%H;nqw%#Q3Vk8Nqvlg+)6k_v4>C9>B!=eOV$E{7udP%f7q2h;@d(v*kZgPf8+ zc>FDb2XpM>kW+|w7os#>sdq<(I#ERey(4jkuu?IpXrA(WbO)JmxqflT$wy44Q4Ycm zzMRqk!AK8&8?fa_ONn=?J^{Z3#y)O{)9iUsQH-i8ZADGJe9M+{s%jg|6pLdB4(Kxy znSm(6Y6rKMg2-AVnt}X8j$$p^1;b|1dsd<{Esam&E!_(v*QynMMmPda3t$P z@*;+eZhpujrmBV|zfv*e(Ok7;B;EwYK}i%{!yy4w2It81UVv_PunwZrpwJG(=kJ_fEduKk@t- zdDWGGY{)8Hg@g4Ea~V?{%evFO1jXtTF~y1d!L-I*ESKncsF{$iMCikmhE|Af@D;S; zK1vm+O=6y7x?}Kg%Fz>=fx!bgOF)FlHf;E}_^80^Dcw_Jn+Odh2W9H`ybCwlni{gB z*@$KOBZ?%7>Aaq^sV!pt2_(1*Ah%VbKSmeqoSsj^BT9+*O)HX(+96f5ZTJ#tQCrar zNw8yqq^0>Zde}KqV}prcq6qVxX{R=)Y!gRCTp5hP2}!e(0eaLqiiZTr&=fnW!D9aq z&q>z)1>fC-0&1#1BFl-O->byT;tV*i^hnue%r1|laWYdhV~W?0kO4V@RL&?F6Oo(6 zuO>4-Squhz-Uyxw_^n8ODp(N83G#BX17%Gs2W&b{l!ZcYDj)IsD6EEdK~eT)lByqp z4I-<8DeC-lc%TmcoDY6XQPLN7kU5a%v>J&`t93Qnh)g%2sf2HICq2Wt{devuJnv4uz~;;wcZ?0(NaIT7x_gJ;qWo2t0Hg4E zge?KDf>qB|j4GEWS+zh0U4v^7UX&Hg1g=ma8bZ{j_k2}5@BCu@eXKE&MNR@~!&AGs zwv0d}*-TazS69E)qYW=EW8HQ9mfA~Lo)=-#SmYKxY}-pjeSr0Ry0et(uj(G%(>+?f zcBa)r`!MLezX|`@Mc8dTC@3*#4lp5Uz@a84jS?<{ERa(PL2O_yLBry3oMDB1|0V-E zMI`do${Ml&g*WH=Yt30Tq7{%8t9%-o@5xY#y%Hmp<-PN^w3XQG>lt63F(;^ z`1Jjj1-(6NppNc|>qQG>qvzZrMhh&i=w@(ppe1Y7OzH)XRP(AU^tXt~8Iv1TDH(8d zO<`ZWATwmQ00+B7+e#FUj$X8~}_2uvN zMXs$MyBD~RtQ*_xEVr~R(H6Fz&ai21c*vG80QsW?LEJvKNJZwG6*}UZCLPfRZApB~ zy7I!KvoqspKt2NGoaKoTQRlovP}FOaD3II`HEQb zAoclz_w{P5Wk%STF<0f}Z-Fi!OvYYeCIK{MoQr2rA!_3f3q+p>DK^lZJVWFJ1rf>`;R|r93oFzNp79=ZjCcE?5My$97up3MS#x18ePR{_g-x1>w z)r00H$`oGy#)2XfcZ||zK5-5ENH2i*VH5(%21a!tPO8yUcx3stZ{~5W+XM)?AYn!O z;hTGLyqiUYP>kx{7nD<{Va3HG0ZOjn8+9EGYIW($o}ahwdE_yOcBtPE4d%0+|Ve$Pa{C>BBt zBZR9c*;S+v5>X~hCT$tpxpORH%gB#Yotde^;DL;Y;TK&jI_~bMBniO;(tklGFGx~U z*s-IvLq!I;nM-GwPi;T68Yj2R^P*7}(<^9w;4%X0u$4~`m&1dCtOGXCct%c>SmoEd zTgH&wQ%$R3^?G$?@39xdy?r=fsO_hCq}IDH?kc&?e^SO9(kfnJ1cvdB7a!X@qv||@ zYuB%Fja=(U>B@i%^M>j^!qJ9*ZS5U~7rZmr`Oe^cx_aHQZDIsL4X9(&zUxqFT4Xja!+a+q-DT14$ku}-&g zwJ?9t_noD)wPwe1>DW$4K=K=GC2bO`345!Yc^ofR@*BH%w)Lld;t;Yw!t&841^Z zXA1rvO}-^(?tP8_3?v#n)i1ElEwUw!(B7d7M6Zi5AfOtK(#feB*`-0uTCQ01hTCp? zgDX02i;G-3*z2`7B_Dbyxe4-I{HbN=o14%lUJw^|f@**Y zVg+{N^;@JL6Zmhw?^)tLwioG6KR)yEG_fVc7u>Mqi|*-gyD-wj>h z`dGd7Z--WMjV^GbuQ@0#PxIkrbn6Ngf}wLUH>eEXXDzo3h;e+jrNHygrc~#;O|)^t ztSKKF%t`ja7I}yn%A14EqlLNL<>S_ucZ2gaC|u1iEw%V=jb-A;3-s8VCfNbn zw{My?19okX`|MPKfR~do6d_Qn=ujp3C+}-}_iSF!LECJCKH}o?L ze7|^5)^JKyhQ@+@ma|EZA;_gt`3w5d56bNMv~M3lX2}kfYDGRu*OpcN{KMNgiLA}? zi73H$bD8`3Q7kE0mOKqOu9IE~W>u(Eu{71WxdjanQ~1c@#uw4G$2!k7{;@wAjU;o4 zQEw>NcYJ9Htg@hJ7%NaBGBay}Iw}fuNm;<@ZLnpIchVp<4wXm{~e#KXLAS zvvSS}Z*_=|AO%!JCmF*^5tLp3*n=;2SB7VsKOB6$Md)jO^g4XrwCL;CSJ63pSH}=4IOv!EC(L4Z096AQL)9F9W)$f0AV z!2nXNaF=R@XZZRH6)>8P9f8VyXp}%oM~5v@@T$V`m%jA4puSBNrG}TOk|6HfDGHLx zybVcI4-`a8EQEB$lmZBw49H%Kz4R|%%6`c#Y5D-3DwUWbADEUbX6{lHkOg4V(imf3 zLs>JP9FTnnpw}3Kv_aV-xKzo5#5&~y3L_N^x=26q4%H$u0*Pu(g>PG}8fzexNR>TY zN6L;{W{|IA<`yLGsINVp?+@qdr|b38GqKSRg~R>*;fG?QXZyp;C+*VJq0rSO`(*cs zD5|ok3U{iD6&(KSqSYGdi}kgv#qeI+7DTa{fHzB` zDvH9|(!vb(beC!6p-@d<@rCTtrd71N4UZJqOgI7PP(0{ST-vwc5u|J3UdT8{xpdjQV7r}?y8oK{ zAUu|k`UR1y??k>xyAMbHly@ubx&`aH%5w1o)W;5eXavb)q=@BRmQdIlgOfyLBqvSz4sS;%g`g`>`WD`daa4z<69ba|f? z#Y{%D^x2GJSxRQsLUNY17uXB4D;U}D} zGt!~A$hDgEA78h=#$_S$KSlvm3e4|Jo-hneq~GI%g)LsQcfDIMI|~nBeFM*6qNP%I zYy?3`M=6pH>yleZK0yIp$Js5accq#S<|9Dl!>S;-s=Nlg?<(|h8pX6A(5ir;0m?Ob zesrD(FU^livB4O7C8-U6uJ$M-_EZzKiHuZCOm zqxAUbJo4?fGLiPDBEX?xxyjIIB-38T?`9URrC&zzOPzj+A9@k|=U1s-yid6fM?s8W zf!Zx!k2PZJ1mZ1(%vT|?5*^E0PkGSZ$;Kf^A$q701R8nx=KTy~qt(jga_uJ$HIh^+ zJQE1yfbkQ+qJs}2o_rBBk?;^HV*9G#a0f!VI#lT9~9Ym8Q z_gPoATh)%EfHoK=l>H@KCHlFAndK$IKAe~P`(WpOPhY=0PcVqhB>NNo#Fxi+paLu5 z^p0^ozdN5^Mo=9=7z6omraurMq<##-;^u&D2R1{2hj{xOzx~`D;~A=5W_(8g>dA$L zcs%a5NF7^}m6?{x|?W0ZTCHE3{MlPnSGkSv~SWUs+z?GEL z#GHZUN@6G`QAIco)#@?@*W9BqrZspiP(;+l**56%N#qMXlTt+WgCA5yC3QyE8|~#~ zc=Ey85BE|{3Oj_McN>;8L!M}cC^u;{pai62$0Y2UleiFSt~Bw_8RJi~74|!z=@s(q zL%aa>OAj+EabkgT!@>fiAndy67g&Mm@+hg?r}(vhH2eC@lQ$4gc>}v{WI(rNS<*9w zLPnR2ur_dQB$0?{VO`cE5p2Tj9^l5b&jEVoB!@d0Zyio3vZmXLy><6iY=g3rn6?JS z2atF!Xv@~9CEG#r^&>v>N#wtyGGjTsu*C93*kvq=>Y8FMj96C8Bwp8yS=Pv!v-Ra` zZS;`)6OaZ#azfYSY0*puS{t8`iiEqq2%xo^iD58_c3+QRNraM?$SST$bCndP6&tL3;T0k>E+G7*Ca18KDr9CY&@;()Zodt2y7<7`_H!g?PiR z;l8F^8AMg$rd9X)d9_||=d@i&z~z0f~%m|t-1)!&0X#ZKOab>Q^U>6wKO@l#j+0^NTc+S3yBr0`M5lSje@ zbRT2CMn+CJ{l0hpAX_{*Ke5<90~TS?YtQuGtVLuM$Ww-nyqEcx?c4XVe4H|ufNp|b z_Rka&GE*q~GP;nD>1FW5glR!sF-aQSO|mj3M8+XUb?HjS>V>W)BP%x3^)=jI4d#0I zXb`ZwGoEVBx{gwI@Suge6;Zx2oHyf0*OB9f!5iX9Ky}VrVnUFTSq4Y&fT~ZyRmgw1 zF|*e%T0;Mc{q?&MG$86*xEBFiGNsaM)L0?BK&Zy`8>_Cu2(h7+?g2= zWQN9|8#ARTrYU3FMDyrWDJLtZ>idD>f|yTw8}&6DkGS0b3HTgEJ$;zl4IamK8P8{k zEXWwM6BM`@12kkAlLi7XFs_}wTC8BqV?guUe3i@-i_sY+9Tm0QMN#Ycj&$~Ho0?%# zwl6T`jipqv{qta64h0Irxj;b*2J_-8e<`Y|n0F{Z52TeZ!I9jfs{>A3yqfRY3*^*C znr-%3=fWB1wr6e-NDPSZ6s7)==<5PPJ{S-R0eAnF=t#dLA_jopo`z=oG=l$lyEyI| ze@hqiJv~>by_}r3WS3W=ji_);S3c#5PXMRotT-gNl36tB7tlZeE|Gt<^vXa;yHs9X|p=G+8ls)qQuq5f5Ce(=)ha75?4WIkq*q z%JY5_z9R+5&cI+LnnO!OwayLlG0J%pL$*+eqgej9wR5dPSMbcu8FOk#&?gFo2|~x5 zYu~O<4QR2%wPWD}p9-;h3sWYzyZE^9 z{7^v4$Lw8)Q@|~o^NO*X^8vx2BcC8e2fRo>zQ6weB{RuPG#uY?cvw%EK^?1~zrXsW zRM?ozj)sCaOlO143RobSQExFQj#R=kuTm}V3w8`(HenuG=B5;e20$Hye%%<&Du7y- zWIYzCPHEec5*{L-MChkbX{s;rt1ME!7`2!L9!Y8Lm0h$GCW|Ft?-uM z)KqXw7>5Jl$;;)m_1yH(;86OxR+?>lEU5TG4J$0gcI}Euh|&r9l;C4NU7NiTc}Q=L87f0Ynm_(0)>ag)M^Z z>;X-COXxvOdu8ZOO?zIjjm0+DJ}p~S7{Yf8yS0Q_wdAC1jigc|mdvgo?v}iB%BlMt zl22fW&CVZdHQWE&tHu>kJNTtbby*Hvk zN63cO@kNMjY}-|Mf*&~ZV8}qefLcOqvBm#qyQzxka1?Sqakh_er7$+V}#@ax; z_s)+wBs=#>QQfl#{Gg~^cO9aHM0NM>?%97kR$Z6=%y822J-@szj;^ng%-LR=VO~3z ze7Pp}^@;Ff6NiTQ&OI9(6t$m{t(Q6PLA_~RUUu!vO4k zJ%zv5<`58!zb{_QEPX$JER}i@e=Lw|FFIyummUfjgM`zt8&asNC?)kH!LLGJNAut1 z4jVB?!o=v~n5X2XOQUj3;I1STqDoR};|&jSHw9^K|Gv4g!SRWqEB>GU0f60uaeUp# z6|v}jn#GbrNGU!EMaL?ps{&-Pi6;xHZ2ZYX+}%MM=pT*7u9%(McVL^$uRJt*#n8m~ z;CGp&EgG^~cyeMbsH?()ctHp>jQeFih5%B+Zl3haL%Ztug^{pK8X#!JF}WR&F-&Wa zd(8O?^e|xPqA_9!BH=0rVI!|fjJB#;VLw+CK^IK*JKupGOotll!3Qt6GIIBktz*ck zY3IitjfY1I$w<`lN@8gS;nPYMkMG@!A7=4_k>T}MhL0YZEnGjCFNK4BCz7*gv!!5Z z*dN>!2PFkG0nz4V(5lZu16%CI6LzNl7G`(#+_ZYZM^ddhNeY}yJ&B*zb#KCJ3=Dv` z^y>AWwtw(Kzyfh0kXGP*`@FiXduL|&ZeRNYOG~}u)_|6}M80I0Zq!QDLQ(E)>N=Ix z$b-AXJ`jWru~GC4W->IvpV&D(R4fkNItw)O_9HZfSV`K(4cIL*Fkr?t38{a$7~Pi1 zY>O7Z2>^4>RRIBgn%n0;aqb*^=%)%lB~}P=l0Q1ozW~4gh*ylnumkI#xjs zs!^<6ii$;+mUFn+LKtgI_6htVhBz0{1eJz70c)e{AzMU0I$hs1J@v=e6(3g;{*-@H zsIhrdDG@=&248=^P|qbS)%W%B`D{R7vbufY^6je3gg|!w)V6~Mwx;*(-S_-ZG*XU6 zADS*)`t)$CFR3*k`m&c`C^0W+)mJEI}7{_Enz@=>>$Cx5r zNH@7jr;Auo-#tnZjOhGDFxfzsN>+$|d%=^}YUy`3!RR;roTi?1*|Ooy(%)0poo(4( z@VihBdBB|~!Mz{LRDDvbc|YY0yuaDYjP>Y+2r-B(21qzggazfNlEJaVJ;DG&8V)Gd z$kC^xoq;BYYjj`&3Kw!12%G<6Gq$+`#t!>apNqkLJ=a?P$lAJfraDGKwVKT$%FAbr zRcE$t1@5PHD_@%q6X<+D{L_93di*KShZsdnlD4O#+`|!#UVvcK5!S1$rd-H(+q>gQ z8aq9XEY9`)U;4=l z%3)o}HN&5aMmv4Y*9j9P%cHk}pTk>;I|V`COZ`l(hC&jSqEb)ByM3QVoG+6u7{VG z;bz+YY0jV8VWbIPBW>)UB7@Enj;+XuryVBzlt!P3Vt~R@=mrs5S`!eNu;wD&Q>nPC z&vhP$go4!{gCo7==mNmygu8U_uzpmO{v#qvQs?3{T?9EBp8EXrL`y!R`zrb=+B}Lt z$lA1~Z~nHovg3wXIAq8M8a4@IF%v&Jf|wo?HIUWzKXEp*}<{=6a6!# z44dgs4SPe$WaX=hD%Fn!%eZ0#I}o^#!4RB}ivzb^BaCMAV>A6ouLOs*{+U!PR7nP_ zz@*zQmjV=;p{lCaS~x8-uNdAvup3@ve4aU2NEs)(#wjr^5F3d^_wh-5RB1H zTuVqBVlY7&$qBIG)RM^MC==!~B_tk2HKxjJezY*{3xtvt*1p^y@#_b4ub4TkX@@hS zS3fZzhvb11xYQT)>c0Gy^XZ9VV!+s8Ic=C>1T ze%*cHNkIvF^Yd4Z7AI1%(oVqg!AZxJsrY=M*-{2&T+0xXC6aqzfihObJehsPVyxEd z*c^yKxfT!WvGy0dA(mpT6bpI1=Y5Z|#nshuG!ztsJ0#d}e;o3kyi^t~ zHG~ch)-L#hMtAlEH*;z;uKGtam;=K%hhLVkJ*+mMEvR_*99_ zO=RW)0O|jQ6$rf}6tH52R^cD{`uA?rdmE~`Ef5~ao#VvYt0!q66a=2gE2(=qQHSG~ zVx(?@j=wK_tTfesMLh z_Y5OX4e0c%P85be0aU{T-0M3I;jP7*1P|6G*0zw(7sAe2TiRd#;ujC7DwN<)WSRS~NU+xXunBA-L~cWEie1)+Djg~?IpFp{=hrlal+@n&hKm9sg4=;vG$+c**In!W9nZ{ zf9OM(-?IJ2mo^$Ny>a`A+1X)LZ9jJBDZlR;+Zbe-JKsHM*w^^rA?7itz1VlqH?muz zDx}y=kRov;zwbNMKf|zO6&EQ$lEtexggwz#iRy? zOiiuca_f1lQVLTf;ox=vvizMY-FcZHyiwMrt+!nRDtgV!kPC$!2rK|-=Zyl;VFdA$ zlKP^X;bD9}asBnz|KPExCe7gLTSbA5eos)M^S~%!CnuOBTqo-CAHGY|-i5dlx-Y&s z^?F$suM;HJ{;DDz7i4Mv+kYf!(NCW{$9ba1aOV@yEJk|l1Qj?;P^rlSJNExdrY@~0 zGW(^htVlXy2tNjwl6D9&WFM1NRsI+P#n?~ac;e9JkAXIS0oL#sAK_%caastIl{guv zF@PVOrL~p!gVv+Qy^&~KIP`3llXRo_5VTb0cL_fiO#n`UZxrr+ADv`_4|CGA z#JA}Zv2tFv1Rca8_@lZOl!$Ph=G0_m`s=Ig-}%k)&ntu%E^jAS zf0WZHKYP!*;;$_7yE&&C>|<|_zY#4jd6ktVPQ)%&mwQK@3Nv1PiqjFn(|?@Hg8|rd z_PF$ow0AHAbA}j70I*{YLqw?a6l2n1en4740#~6HQ2AJcB1#(}*t%a791Pv<|27)( z3U+UYK6r$%bvGiSE}F8s#pue1dg+ zXmB8`?wSwG7RQ?C4KWZ*h<(>g-F(iej``je^QccM2 zP4=;(HM5+6Aumh;)3YT%m$*Ni|0&~G(o@wrI zfN@eJb50i5(OIO7BEA;fHxl%A;APoTN)E=8lC*p9))OyBo{XA)bS5`2{vkWS3=w7(;xlj}&W>zTl%x#^nCfA2&q zf+%GP_MdBXFa~M0sB#oM=Nd7E>wmxgobtg3A!!%tU<#cBJBpb~S6&W39VU3MMP?Dv z7JX2hsft*Z;EX4gMMcdvIeXJ0in6+S52Qs+HxI)W);Fkl#h-hj{g83)!#jXQGboG7 zVg86*61~dcC2N1qU$lhOK%`W*!q_f`Eo^_Kdc7pCj}9r4^nl=1ZZgfA6l5!+oT8u` zeB%%ae#p|6rfBF{$)}X!qO5++=kt1TDk(#ws_{0(C)JS7N71ZPNRS%;-i9yvi6FGU zu4%stS?%w_*(GI#e#OC|J36*ypl(2>a-|9A3|&^tQD>o7yQI<4UeG8-oP^VrFm+(` zI%;fmwR4Z&z~#j*PwJ`fAIKK#j*#Kgv-u`3YVE}}z7K%3FWj%>DoG9hLEnt%kDL}x zI;xnZ{q-;8CM0pZh!(;n^}5h`YO7n*>04=k7D|PM(?S1WCIQHYfCe|UP&rkO1eHaH z`*h~q$AM4rIQSZ1F*s33WZfC#@qjdLtEf3fGjUn8Dv&IXl^aJyD`Oa?B!Q4-36l{= zXcRwLSSf%#5*A+IC3d)>2CcjOfnhl=4+s2rTS0Xip3bLnPt$tu1$~`+KU|mzB(f+p zKmxLfz&`+Jvd=V57-4xxm9H_)Yh-mu4jab=!|Z#Sd#|7A&&2&wAY|tNPc{?^Nd9;R z;nM@ zF?}68_V)u?I-Coq@dO`Ntymyn*-TJzq37^!O^YoYF6*OO&k@rVp%DCp1e8n-#=%26wF z;=zwtPW(N(^2CXLq+f5Q%JoY{_&+A~P(PgbB>)#ndVf|xBc)+{EYB$zw;XAIk@kGE zL`T@TdLb^i8QhxKBr1Qf6O6s)-b`M~0Y6|_&Fg&Ivorx+p2M{l|1&u_UofAjuW zEE)_2kR2|f+R12sG!Lslfh`|=`Mk{0MGp-sf?R1W;!9=&(j+(`;?%lyip*^l zl0fk+f)rM$w%Ah}pX=&HkRFs9hHBxUBsI7?F@}0w;J> zmJviSZt5XGu`+Gpu74&e!2Q>u;ZE#Bm1wwA1gP__IBpd1wt z=WvSK)XYpNIboj~d*#?jQqN$9wf}QMQhySfDu+E!$!!ylk>$=x>v$|_kBs5DQpN)M z{)8ky6)sPOexi2!l4xp@YvCLeU2bio?#gy1KQy|l5ku?>Uo7T(^P6e=65iC!oHL2h ztG0jHZwtw!dk}E|2Y;a`pQ2vGC!O)baYp;e^K(tU4Zyt8TEs~&NRNYp0T1svNaHeR zq*m=_T&+O)*XTKJdnUW!N@ep*Kird!Yz|Eia?l^!3$Pz!hQZCld}Aouelr!MF_s%# zLP3SFbt#?S6$@Ua$5%F2;IRbXCB|kh7g>F90tOan)XfMMwf>o#j6VGyAD=;$WYll@ zu_XKUsZc0F@62s{WHbyc(xGY*j$oH z%o*8Dz-z>0zzGqM7A7620nfVwqCjgi762DFBSMkK!WLQHbM=p|-XqIMJUX@ON4us> zv;EQOLjI;;>79|_ZlBucQ)B(08j^A-S9F5#a?J?x9BkVLhs2m+3=bQI75KMG(KpYqLHQ;3lMIv+2-HN54l_+Mx?9*YK=?wrmb^Efumz&JUQms^kCQAjh}) z+Ho#$_V!@uZ)POr*!e&cw4%A_IzOz^>1OV~E0J6NjLPu&9G+JDrtYUED>(}4wRMX3`Go*J`=5hR^i1{!vsb}%CZJL0AoQ?o^1n5 zyiJ&6eNrx9OLMjmlI#Ek6HW6m(a_%uU;fs6bwgx6B62g~u$G*k99)iizx6Lxpy-VT ztbh4(X0`;h$*_EsdC)zuM;-+yuS}L^GliWK{e~fVhsSo24zJB^E6dPDCZKbLd=i5u zm^9;55(m|X$TTSlAo+HD<4FdiRfl7QIINeW(b@*>8kB43=32!yC|_2bO29HBNnURV zUL8$EWl3#wUR8zf(ToltoYQN-yLgP&+v7S*$vNl-#)`UnB{w$5UsHds>1ATqCYy>cc=lB;2?T&&zCbe&aqB?@HIK+ii;b4Q>&y>Lkb2weaX_GiW|HJe;?M9J1* z@=Ej~)!8v??1{zp(Ag97R}V~(h03SolTd59$`Fg_5PKYBhrCXS6PiZuB0@b2 zovHv9e8jnx`w#h@O&8pULps0DUXwlb@D-^l zB2k$cs-~`Z_*8bNsoSMY1?5BA=2j@<_^A051$?R-9xj}5KW zdE~?-k(yI&jub`fDpX6P?As;~xdQAP38;PShd1v>biZ|9mZkbwPPbk!^UpwC)H>z^Q%!RUjp{ANd zV3MN`p>kb9I6j~d#NGZTKGpa(89#Zh+faVC5i zI+5n9&onPVXr#c9rY~-ppEr!p^Mmgg#;GZqd=GG)>FxF<}7psA<3mgcKZ6l{q)7Z%cz!qL{}`$Mo5^TS();o z^rSBp8>G9>W*T_TKR55^mD3N`dfhimP) zA$Ip9;LuE%u033e%a@C}s0o6QL9wXal^ zB0x2F8oH8g24j3(n<_Q4?lpGRYhKIRe~q_X%1+oZJ9RV=I0fnR1mKX4GQ3Ep7j066 z_J1+?HGExu6f)Q2u<0exM~Ejv{5OaK9M-WebF@>U5oAS1iU+Rt0EiO>JwXA2BWAr^ zlD0PAdrXs60TH8j8HLl!M#?x2O|m7+)?WP4{(CQ#z4Df={b_iLvUelD_L&i{p1$=X zy8aMuz!49d-I7lN*b+9}PC}SUk7Ff*{Q@OGqq_ldT%(D$9s^TJ@-O+i@C`WRj+K_4 z$Gql0V*jaW4{g_dg0H5kHSF}IfE<-G^Pe|>C-&kS4WpdL#pH+_lt&W)0S}y1Y&m)o zeys*e%C>Sc0QB#~C~iZh_6hm2VeEY27j!#m+%t?91(tf>z5O9gt02L*hDna1a~_}k zslf5@d<-tVRAK6=MkjzhsGy;#F_EpusSPx={1O=*!m&!dLI>mI1%m33?EpeS5p)-g z|NV#(RDxpJi-46_Voc~)Eq}zeb$HEMqSpK%Rd5%yCbHlaV5u*MO-b?j2BX1?z@$z@jdjfNCchy+*!=~a;@#0*zp)fc_6Dl_qp4^f48CQDXGE$Frl^iZbu z3uB3x9`uz(6hn!qGI}o+AM*jGo#Y#u`_zXTtWe{aRiv=MEO&;LkIX6P#8Jox)PJ-N zQlIs$x;DN$lzIo(YOp6vbDut%jf;LMksH(ZuRVPgXX779h2Ftkm4S}C;bZQvb~)@l z){7^f`Y34AHnwh%j4v@>O1GvefLq_U45U^$J{+<&y23tXe3J-Ssq!oC0ke? zTe6Xj4*@>F7&CwkHm86MM<6gs9Kx001(KM+hFJ&+8FTQLs_yq*z4tr5zpL|iT$E&Qmtd=Cqa6o#-_(lYs%zg+F>%kq zX&ZwkcYYy|7sU(T2UGqjwhW$C;LB=?-d8HI$6^75^dP%`tI^w2afH}+tam3q&EH7;?iLDAAwo* zZFqHkQwjH(fqZa1&vLyn2`^;vfXxIX5d@lOp-&z;ZBLL;-r^3egg*}OPsxFip?2M@>Onq^gKqN;2e4gy}eoAtKXOV zFmHVsiN+uTi5NL_;Jrw81X4U&RO$wFs5LIU2H~CVv#TzmP*~9@8=2c`?Q27tuBnlT ziZ`|z3LW}LtqmIRLwxC@{27y$N=Q}9#Z(u-B@q2Uq*ZuGk3^pRVl--e!{{~kZI=~C zk>*Q!S?{y6`4F2Ah3-D7NV=rnK5ipR3bpY(Y~xRWV?oyOGm`oc7_lHi=zK0kVRQ-P z8kUfy1CWB+71M?5>!<~Px^LSUGXGw21(Nk56`xbDZ`*UrG`m(dRKfR=`d+RHcDk+a zmb?);0-iH60!DKrV&rcL#fzh}qh;ZPs28Pme$SV|;3g8_T?^o(N2yK_ua@4$sMZ-N zw9w5n3A4jb0{{u2)ZHTqljaw6=@PWUDhk?(aOK74aRJ)UnQ4;9S7OXcrR&XX&Edo{1jgf z=czLQrd<2Zd{b8QjkAl>9|)n8LfW-`6(){7{KO6GW(~U$e6V22Tjp?+fhC)VIG7;r$iV-6e z;y!QL#(sWFnBOzVx39N`(v4{rjzq$6uT3MyDda_pN*pPUlwu@FK3+1PdkpKA{GA%` zOrPuNnNqmX9nREwo;Mg7p9mTr^P69;0jvROLhI`Zm8o)VXGDt)_G@BvxWwKb6}A4sm}Zs!+Nu>t z^NS17-qIecRqBl{EapdxyZ78wOXpLme7bhiVtUBPrA@Pt-nqvNrwgW;&KX1Ls)+~C zr9-5vT?f0}kE5#*HNI3lETL2ZV-sGG;ucAp*KOY%4*TP%aIQYSe^WT@Ms<#!CkCMn zx;ezH_opadcC`uLb54#&80On}EH8n$jpk({alXeTXOp1T2w3#DocHj!A26Rpb&4!H zAia|lWZ50V;D87ZGsR;7TpWU39BaG5{t||f=)Q0$Ub(b%NV?u}oFZN-#fgQ%;zQ8Guz@`DEO-KqSDP|3 ztWhR~{BC{1yAD6W1B@gKg3&Mt_!NR@g7`Bit_K3;y8&?h%g=5*ilSO%f%P${e7TV- ziz3|b6gX@NLI_&c@!4g7l7mGzW!>(p^gZ0x8nusbKHQ}3k;I-_Efvp#u9%t?X&n&C zhZ1=od7;M*d6{a>eHr=)9KtEJkBUY@g`#v3^g#VTSiK+=E2D}E*$5&NVuPCXsRUcf z_9hZFq|B=8?>urOcO;)#%pfc9kt5!26a-Uct$eNwO$C+0cC)J@+DSzgW0G;--rwAt zLZS|o3H!~xG0nDOs2eagW;n%|Wou{_=eJ*CUx6IYJ?mg{!jNqihU!E}IFd($NbXy3 zn?O)D=8H!TL;yAwWF{Hs&R*bW0LJ+6vfq#my{1bC7Ca*Q=iCCa4v?^8nxUngz#~Hu z6W-1l4V45XEfW{vJi-LlOqP>K@xQskcm8U zYOOIj^qxp7m;&i05Rv^31019dBb>tTdb~?u9;-C>AbIS^(CnAChk6GT3yw+sY0BS& zPTR(poU-L)8?d9cuX-0_@IA*m8%SyzTr}euUG=fPiq~xL;rbf&qnNC2H!MLx!ZTYP zNF((CY1ONSXgMg0r%j!aCBrbI$pI`{SX*Vrha>GB=yspmfLD17}GpMYj0+a@mkz(;N6Zt^UU~uu5ds*Uuk6A51OA}p!dv`Rd zhK^@@`KoeIfx)h!6eLWK2Bjf@$GwtJveBrSN}16}R?B)WzsNOop;cFm^6+${KX(8H zlQ@5Tt-s=~(_N=v?<~8?U!c|Y5VP@5mg)x(A3aY&)M1@07HP;0=|n zAWu~q(+ClARnB{Z6)F0YA2fx0evvzgBbkA`kIVt^H^2k{BqhTyrdEE>|9B5F(6Pmp zv*v+sA22&l&3t%f_Csv>>YcZ2J9>26EjyzlCEM=rx9!pmo`*0Cd_S<~74SF}JRAJF z8&RxczHwt1R~wM0l0OTqI1DYp*xY)~k@cUNU(Cp%sM3DEe z9KB8D?O1WAW61?INw|cVy*3^)sv+BI80oNR)~kb8^v6)WfDh@vWJ5|k2fDkASElc9 zubvOxmJr0GeQeAgP8^d(BhMFRJ_~%Xfj~I2b9Cd-Y#s%E4OP|Rh8Uj!doD*YFoigZ zK`qDX!WPgM$Ol0g^a24UnFJDVjAY5snh~ozd%l%4P}>g>obivcoN?i+dPf>Oprq+d z6&bU%k{)8|JD|L~nc(yL73faN{dDSo@wsw5p*6pC762LgLR6axuwXY1k0_d4e8+l0bY1NrAU{okaLZ$)h*& z%mNsTfnEg0+JlsPLt(bqnC_e}GMt$k?mP)`4{7z`ywZ+!UYD#%ay`e+rW4bR3q1fH zhv%kYZ`t^}!<6}j&(Dp&iw4k$;*EaZ1Cqq`S^up>tZuC}6xUi{8=E9&GBTP~Yl*Z+ zug>`|$m++t4ZL2JGZ!`T12~zH)yLC`ag;};-7)`drV2He+Fg`0m@A~i`LriR_DkLl zRV390Q!q!XBej40H+3Wbzm4P#>m5W5%if=%oVsKZZ@~A4ju2_6nQ16R`LJ@YSRXqIoiS00sR)6=c zMQc&h{v=;sbezRf;lJ3hp?;0VAN1ghz`$1FMFRxPuO0>D52!Z>S%(E8TbOkuEAVE} zex1NQ$`r`{(XxuQNz1x>NZ04R6Nf#`_2!X|Wau766mli2WGUhCkR!>SD23P(j)6{! z$~{B4X5Mo(@9+s+uMgdAS%%STO;hem8??q?c6PYbj5N>%kl7{MNybn{S`-huIB2ON91W8E3RhBMryW7U-p&jqY4T~x7eLFt3eR<4lI9Qu5oshx;cyqm00hOy zzPOfwBlxyKKJp!keW+^<=$h*C{M$X-IF=gJk?HuFnbA44wj?E_Qn0@;DQiOjpnW8Y zw#-tK+Yx7&R+Ap=meW@%(&6!cC04e2^0;9fKe@W$6atq$Pkl3=@4npX`*hutxp({!+Q-C5yFnGkL~C+1T~cH&4yIL=-n~K&rYhw-op{& zl}R{WhLMgT&BRQayy97E3UN&GVf>x8|^rOHNsJ6Oslw8ppo2s*iw+>b8!o`p0v7g|> znb13bm4>X+h7^+S?y!$`$4^GU86UsK-isgEZ*G2IIfovYo&PsHu&bN_1j-B@gBQe7&uxUULjMFPR41Jjq=1wk=y*^=L8x1# z^^gWw=SOOb?}i91t|Pgv3i4h_AZ`N(u-(9k+^a+C!|j#H@md|c28pR!*i#b%QXO7b zmJ-(X9XqyL38{SDmg85}YmIs`gMU+=X~7=qcy`P_ZrHOnr`AA1?d+W_a$8H|JnW>eUtClvA;eU$TK4N$q40OfVAZ!w;cCu z2Ffv)-Z33P7sgQ(k~0uEFGJ4l*0f{iZP!r#X3uHH%8cmy;E{5oENUT5*j=6M9oIxR z)Qn{drmgF?!BxH!b5lbLcP#9E`GGU4C=2RW?<|hjLdjD~B~~h+iWN~*EI=-A^`L#k ziT&1vP=k>Ci$Jo--~tK{Rfu2^b=3i3s!3ND=uwz=ev}#zLQlMZK)5*X!zF zra1UBW0q(sv*1@@qwcZQkXydOuc?k)zc+w=#YmCv-CwVN(~Ab z;a@NOI=7Q?J5;Niy^(+dw9BWmK{_5TB2-}t73!_Kf7!C4hPgNyVp7B~jv7WpLi*qQ z9(g7IGjuV^ zG`I_Mo^%mtRNW?FRtH3%sJyGziM*?T4Aw5-9|xigZk+6V+h&Gxm^JR_rnzm~0CdSw z(|m(zMm4!O{XcwI53LsPH{o720_2RHLS^XB4)r-SB_uaZ8jX97W3_Qlf)O>OiGWlwgN@movCnaITe zKYil*F4SIM$>}+N9%(K2LuzQ!DLAEIcbRsV;o$G}~P&5|k&s5+D z$ekO3ZGu)(LE)|e8mab?9{aodCnok!_`w`!0HBShGTVBOK>x#vMm8!ns>n{r)(3Sv zD%&BNKWXQ-MMFO1m@P;?(TcpS$y}z{_Q-jgGF5% zisiV~R4)Hz7yq7Zy(gBPnR=EQj!f$Bd$R#4DIoRC*3HjL_wmpgvAFme|r# zXBAUu2{U2|x$u@)+dqOmYI^BOe^IThy6nov^!df+>MBj4$5&SXsGUFWPp9(;7~p$t zno^+CKLj@YDCWQrA*Jw>f${@bnyWy>F9^R0HayVNe|8+|fsDQRV&u{RUdT7(Tf?6+ zzjA=F{zbzIJ)Vb*vIH?D|9HqU77HOuzbY3(z5BZnYjU@tD52a{x)oxho33^GEG6Xi z+luAhwCTFS$_-{FIjV(1+GsLk-jFs=IJSA8EZ=9^&IvQU{^_(88Ub#W0o%-`Ze6>e z%Rk?Q^>i%`c<8l?f-JDHaJdLXlsG*eOFV`&r6d$lM$#6zm?M>kB`V!ZxB+=DK&{{h z@by4o2W9ILu1O_zIUN>4b|x{GGAB!_(!Wb*N<>qlgNHS(e?Al$+#PE)_st!j+t+Nw z4n4lGduKco2XZCmV!GzUphqHa*PYT}(s7v{2@fBHyDE}lz^+nA?PAi*;8gFmS7iq> zQHuJHW(Km`9=W*t^7EFn{koC*tK!~x+DX_7)FT*mC2C0r8z)V#?gu~eu}c${Dy z{?o_mE@MrTDoj`th#uU+c&rMEhh1!BK9Z0kr?zc76_FB=-vtidko(Fbc*<2>ZuDQ* z-;lAVOGCH7gOeR9#%w0HGKf6KR9#JlW^TG^CX{-BVDHM5eKjEeeU7saZ-*0-t(mE4 z9FTu5Wgp}-n>U}+=QrSakuNVvJU>oO-3|Vp-44C*G`hKgr|R=NTt_%km;*PS!(q8N2`Gl-_P z_ZB8a+ZHDuP{luM_V4Ih&CUZ%Tw1=W)gQ|y(xs85a2OB9pIghATa+gT@e_D5U4M6o zPjLF-p-VjGY%Kw3H-9gofbe*LlYYjYwKH1L1;ujhTX~Lc7Q8Kk-mThhJhCH~+YyN) z$wb?BpW%W7GT7sf`=xyevfgiF)OuKDv-Df_m3Yi?b}rfZTRmg+>*jsB3i@Q4u4%jk zfNkgNrrvL4^g9s8^&#HHa z!Qx`qnNY2AcucmTOiY6bwNH{%nuDU>iwoRJUBSKwo`BHC-=Yu z4aUY1vnux085`61jv$P|04hsxc}TItWu)e8MauHRNvk|2utuLKjExIIJ$P>%AjiP{ zjr!1_><-2?v)|txifcxHy*@mctkkP%#yD30-;q}R7lC%~A|GfhKcc3@H3)_$mO-A6 zL`I31k52~gFcbj1Ag!A|B9aQefg2Gg0u_zx;%oPlTu`h3Om#+5W(Sb*57xAzC8?et z()SKxMHvoS7NRVhq51bC*2!d_rux46_kv|0_^8oi;}PC0-ss?LnT$0&BCbP~`vA45XL z$Do{eX!z`s`|Jz_nslC|?U{9X`ciwV+Bt2@AC(8|GAoKgGRqUKUF&<0)?uxWSUwz_03i;N5^wH%Yir+OpW8SF8~fc@?=6x$^-5A( zLWwD<8Iz>3^^bz?8+R4nX86Ofx%caZ)E|?iswC|~fD@v~;*#{qwIK*~J74sNfv>ey z?ssot#(@fN;4zaCne>f#g%>x*9N>8pwm8g8MhJ)Dt?aY`J;UOLU!zjni4Z)rLxX&q z08fPRsl%mY_Dm7Acsh%GeLd4T8_PyyIYO^&jGg0rFVMrOh8Jvv6cK|OF5rGtI>vt{ z)lzc;o{DImT0&h`2*wK3!Sh(LJ&J++@@Nay929mv`(-w*CDLKG98M>+Y0#G^(LRJ~ z{lN;y$JtwBZNv-4-U`?!_-!sLluZhY~waIS~Let0)4ZO-r za9>RX%MgpE3gw-4Hs%LHEc*ks(yasjCa7U`9l1|NM^PC|ZvfeBB{6JCwv1aKN$>%q zC_refowF?5c?M+W2^NxTEmbFM6p_d^ImEP4RmIbTmPE1+t>rkd8OplK5scuec4srm zXc$e{CAc*vNVJV+`*XRASX^oj`Ba^#_#;bH6f z%l34L15dxix9{QGjb{Q_>Qdc34_lR+zLIZ!E88nKobmV3Kfo?n<2{(CHt0?taNxb* zIE0tHrl)6Wqw59)8}L8?=03oxZaR`T9hEoT=X?1P@4D3dKxKM9GTjmCzJJCN#Wr&A z5klG)U*V`>$w1B#Ahx3Q>zj_)n>HQZwCToemzzPa{?;_!BySIqzje3AU397ry9FSmz<~*GzQYoH zm7t6#rbv;609Jv!Bm^u}DY*2wj*dZ+!5{k~e?v&A7PTU>W{p{@60xEhPV^>ml&XAW zx-p>W@qBzcUb-@H(Q<3}{x$S<*+zu!iJ)kNC)!c}3;1_^aBvf%;onpyZ>339q{_4^zo`oZLL zw5n*Icwq;BCJ*K)Y9Tj>$gswAL#t+(CQUMy8!-ml*+qdFLTEBSIscUAv^4=6Ozr2} z-8^Tg&e{y@T+c&uWy};lu>Rb5j-$)coz7DN{PfCr;^mKCV(*d^_Ma3bJ{F1Ra`EtZ z{4)FN{b^$^Xpkzn?Wqc-Xgpq4YbaOJl`KFlwj_C0Xd1-f`C9Y%4oU)iTGZ!G>}y&h z-NeY_wGD}pS7ttQ+cobQOSXpIuyDtr{5vDaS}m*xNT99^G_wIf}oLyo(!%_Y2v-6oH3}3gt)$WCL24r+VIw zA$$&5Xmo5dpWJ*zCb_^Y$CCu^fEWu&kN`?p843`4p}ysS(0WAjUf7jF&n4xjM(3T5 z+71|MTeO(6>F~pnn$4<`hBqi`!QQ45KX~3NkSggS5$w-ZXPL9rWzJUD_G_vX4oj+o zx2&dRgWVT@2BR;*r+yWAG-=IVfxL1b#Qe09)fWZ*G)nQP45jI4FTClIMd}kjjdx>)o~XJYvEoX@YgSG6P}`1DT=o zMp0GRHq8`LSE%Y0DcCR;DpFXgEZ~$bREyaI>Fh0;XuOt=hSO~Rg~<+^M2w3CiCuyc zjS4auOxAyF?f27hPfBMO^H$2urfWv_N6#lzY>+zW8TeS&GG8+&c*4PUuAyE^Uay1w zLgvJvHpj+EDublv>v|Y#f?6+0aGq%$QRe6~#^hc7l^F{q#i!M%)4zNP(Rt%oe0g#HRstlyY=7cX$a@!Z?43hA1AO9e( zl^Tz0Vc%xZG5v=3=A`xj*EAu=eO^|izPU^p;)R?#xhI4~tx~>xqNAyXmmMiZ<-yrO z8G@wjM*DY-U)k-9KClf+XK(fjrXv-qr=%-VH7(4YM3Po9ysbQYXh2f6zEo+1f?2CM zQ6EOoJkc)Xb>Xp!9;BjU$mMeRUXph0cM~uxgr}fKG69sASr&9TAr0_URglmZ-_S%& zfSS;A$*zx~bzTb5$0d}F9!mE6eeK+)oBbM!E?zwM)25+DvWwGM)lxM865D%=TgPK% zq?Br)&T78bDH#>&Jd&Y1m!8yzRWlXIPA_IvHFPt<6igjK(IFYWH><*%fxkE55poN( z)OTW(u;}3yte_%`Z`I>$6JP{pK?|G{a{}-)5BT(C0Mq!m9D@52fFer#0HA0RHr*F@ z-ml9@_%>1=;aSk0@LC-q*?JV!EkZIJizV^!ofRFW z(88w}zTB$7p#Uv_15$tJNN*8!EyTEpKSc<`BUvyEB=+>eFXHU?&Yj~@CP|o>!~Qy@ z7=4#p%X$zu?d2s55a}X2fd+AHzl&_G055X}`)VHCN4#3!;9qTYYWYMWF>rWde5P>l z%$b9QneoXhcN4W(+|M;@{1&%e`92=Qu=@6Nu>>d)t%QfE05^_HaS6 zXK~L<-~IFHuo(>ro335EeaQ|ZRJ3}>PlRyyu*a_Cea%KT%7?ka7AljrSXLY5{(C3$GDHAv;X#Ue{2Rpf>e1dbif% z^$u@56*f+PiSEUpEcBuuf+8dadWSah50}TbbGZsCJ#QWlFrI>b$6<;D+zGhzAQ2<@ z71Z(OFgyeyJUoXOk|o+smqYEt?NGV%-B?OJI6G8|CHrR7R4f#CTv-~Nm-8ttwt^g} z_#3Va3{-|?M@^>sUOnCzOvZcfC?->?<2sF+rcRttsssH|g~Q2-qXKm?8I0gbX@I~omQVF^>)^1Cd-i--k3j1r`Y7C%FQ&D0crrdCI6!`9Pi zA!bAlhG$$CIIB+u@7=Z4Jznd|;Lh6kj-WFGR}EF^klt&<*9_u)*aCH$EKTk2p5q-J zi4<$w#&f^Qp;m4M4V>is{Y~T1`HOI}w>unrF@V>FlRFWGinRJ#WCxD#m4p)2Y1`X9 z`F=iRNsMD5f@*|y{-q;u6E*A^zV-LJxS21b452C)Bmj7LJeOSW~A340WKl@6G}IRR&c< zQ%XrQZRC#UkOC-oeb&qvsqe3*T(?w;MU$ymtVr+dJeUvXJ^Jrj<~O@3wC-y2lv-c? zs%d?W=?Vr=N~G~GA>nrwWa^6Qg6=U7A)~a_Tz23Sr$gaLD2&&s9sU;*Ukjc;>?uB9 zA-dV)uCo;p!oLs%w0J>*QnCwnm%#arJjT&RU(Gp4;eG@bEAsuaVks}>Krs8n7KSsB z2+G?%@gEe~kQKf)y8H+0+^;{?<#oPPMgmxXyshW&=%Tz&UiaYbhfz2=8N4?)a=fJn zuUmXTNBYKv?oQ`dHnF@lI<^YRvX`#`n$0Br!AKO$8yMRm+42bki#vZ0W_@5ggNNRD z_eB>p>EXG?F%%=lGunR4aOnhgVV&dKFeY+)3I8#9S2ta>c9(yee};>Yw*&uh7npJ| z*9lW13kpRp^?}tZI@!DYwYU3j5^EC-6D)O}={`*U!A-=Vk;p zcStbBa4`p$WU=#M&fdrziP>Nu;3N<2C*E-Z*!41Oc?cQ}oRPNZKr-|Wo*i>17BH>7 zEtoayAt82^OR@||abZ{pvwbK)mpMGRcgh1M4H#MupV`wbLAMGS8h@E;e?h+kh387O zL3=QR7)wQMU$e37+?vd_3ZE*f5!wTO#@j;l5kN2vaV*hw>nQ-cBX=Hl$p7)hvv0W` zzueonvp;a(d28Ulliy}DGOSH>qCZx)OIqaA*3_;rM4cP($3IsdC!mb|E6iZj8BaK~YceH8Gcke@Rl?wk< zQ4YeV8NTOL_@313uu*VO2+3yb#L@1M?0=(;Ok?&(rfN}9Nm=()+|h7vs(TZ)0Q!Z3 z#4cP1;I{iRGLv4#treT85LJSu#1;OusKY0SGrV<{jA*qIlJ#v;$<}Q88;b{y$gR4R z4!6k+-i|pa9mz_ZZmrO^mSF9~tSl+RF(_ZLVMUUQDl_E1KH0!;!WRIvifO3iiw1%s*rlSavsSfWXmgnCyhgz=Q)&uXP<=$ zh2}KUNYDVR2_sv2J1$d-q%GITn@Kmn#xJtU&2Un>#s!mFT4-?3opap#W`Dbb8GFf3 z&Iix=2Ygj5*V29;Q9p9oG0xK6*en%M@1*+yoO(8#_jwP0t_kve!&2l{04z?vLOJ(A zj1;%v3_$57-(slM=rvFYauV-~Yb1rBKm&+9u>ihh6RIj52egLZlx?zxf(OPt_M;CK z21ZJG4H3dAgdnC9^Wl^jMsl!$0xDL9OCup&X-wmT+F46^KPrjdtSPTT)~nt3@B*Gr zB9TbN%Zc!-gpm?a+(^0x3?eQ}H$o8<^z`TIdB|bEfK0mExi`aKsYnx~Il_o!*V=~lk~rh9++2J7DI`2JhQ5z2VwG~a*x;46;7EspP>BWlI>-d33j z`ikfGv)s0WDeQ783zUyr2-Qt?!fIaZD(wP3|8{A()KCS)UpGF^w{(n5{EcD~oHki( zfI+coDvuGJ_EgoIemQNT{)u~fch|#9KC=GYpLA2MUym?;O=|K1a1zm?|HtxZP3Az&32880X=TFX^pF7z(hw5_~n`z9> zpcq|DMqR*U5+(KICtmaipHub8N};dFV;VqHmQ-EfqKL|R+HEBN9fU)#loG|1#8e@c zK^a0DX&_wi(9BNmZqBL3@I7{TchJ8%-VPn;mvjpgigvaV8)1q%-6i`%Xv!#*S zyyE;EXgxuxc9Ou6}otpu2hSoWGgh0!AXbL!L%;ctd>tZ3rh?`0X zjWW@wTrR|SaKelmN+`DVJ1px(egn-TKTiee!%bMudU#=Czy|`bL`WSY>%H`(@0sI;`FK>Slr5>Sve;uIr)^ z-OnlK<73(Bubb)#>gNe<3-UekDIXuXi@z;HIC0Z&;rpNE+f#hYPrGmD`>cpBL;=U^ zCG7+{T|J@t82b8{bUwzfXTEdXZTzk!|10>`_g(xZ-~T1P#XM0twn{h<<=`0smf+Wc z>_NKJEoNX3a2e$kL|?tiRDhVR;ef!{ zmnYZHuJ3Jt8UxU!Zmn;jV z_RQU!xE{2Vg6TkMG)Lev(&IkD^4E{)xsI32I}Fk8*R-lOv@@a+P%d)nuczRR8zl%Hz}3sv52b zHQ$lDmjk3U2j#%lvyK7m#@T?%b}cb#eDfpR`UHKI+1HQ#!DX-701Gc~9q6U?0b#2f z@L}o_=IenM2g+Y0Nhz-i(bxLcEOiw57zm zhR7}(p_JRtHAOGc!KETVn5f)$T(`yCQB6CV6K(xCKXtW!99G`xX@>)#Z1p+1|GA-- zU~j&A?F%z0?`b|b(<_`!FFx1Yxp;K`KYg~r%s=d715m%nnzj;BQS?TAhoQc~ zw$v+E@MPRbvM*8fq7Q8KL&FO4b-g*v%?xJdjhL0>*Mp{A(jWZaghXxhHW#KZ>k9sN zmj_OrIp zxko|(8#`y{kNr1Rd0054G!jbZA4KI;|34e@?Wv-UvYk3x8m?EHSH#?urx=mWZ&jP) z?^mmtkRT4!I^T`QGC3^-{fGRT;D?4b4NzVU$XleacmU-Hv>+|6u2$-b2lb7UW=i5f zBpNm| zvwkxY>$GDL^Hwb~6GrM;tq_@v_OT`iXQ)sJ;mw$xH5RRXlgNIM5LjH-BUf183s)-P zWiu8t^lB{DBz{Qi_B_Y+3Wz3xl^%NBMwE$cIJ`b{$&bRER7(`miXo57NMuYUoY8dj z1HvX!ZlG=rch-XHO0_j&el-2Fs|0Mek!hQK7a6yU9hh_|V!rdg$5DlxO4E#wQ+*mq zqGKvh;~&eoj$3fBX~VM_f9*Ei=-M}KykqM(;P~Rc0Z@GrecKYH`aJ#krm7y))YofT zO4DxY)6~ni`z@NfplPquR1_&c*~d$$^Jj1YGUV$)Gp_AfMmt})`v5pKe_SuX)8Iv1 zDvZ4lzkY`-(1$i61?I(CucYcsh$0{H9JCMQX-(@hdATHKeeu5+X>{e~&Cpb(; zHfuXRy`VAs?SJ1fL)Gewwb!5En00=6t8V)f9JEhuQGI=#JZ9Xb}nqhUsLsgA5!kLvq2Q) ziDlI^)n&z}Z_=91B?1o$frs~0C%w2D@#4I$PKZq+mJ%{+H(ovh<(c&?J+sKh9OvrJ z>xWAKcLAal_xvyC_scA5{^)7JdBhQ{FMgurRYIKyNk3w53RS$o-*_L!T7^Eo4|aX5 z>nL|qfa!xQ3jCMg%t(dj%Rbmh8Nk4hIhqdX!M8gUD7PeWXKER_pL$iwAO^=Q;FEBM zfeYw(Y243IN$G7WV0a1dX`Y1Z3T|XmOg%m_JrdVV_OO9`6P<7ZS%Pb*#ilyj8;#6? za51t!TW@T4kUmxJ{E}+zcH9_J-6^_Wo@a~mWpqo`iZR!ovs4!1`NLiliIH#+i`Xb& zBqMjL19Q34c@L_Rp=Q`sad?KMvbEXlK}9*3ovme&^v_HfI~;S)$+;+0V8(`Gd-q~v z*-psKIddj5H#BdPNw zoFs;M-dipV{7>H6M{|a3n|91}lX zz0YAqK7yTZ*X@#_h1+JvoEaaV!CM;zLP~bM_)poQk&T#zf*Hvg#jLF7GMOCtGI!_M zSMcop9E3ftS0Zz1Vht_Cvs^<)T9^@CMR*F4@MO#!@8*Fk2EI)1y5~YqbHFMwUh$~8 zJzYloq|&~Ug!giKJA+}qHjL`~jThN5g!- z=K_Cg^HWQF`*(bM8$bUP-=`dPBXdvrH~9L@pCFFolfZ9_Xb*H#=rdHU7LUJ$3}i4i zlQ$#GFbw9q7T#o~R&ON~{2@sLQdi?+lF9vY6&_Vd{(HeOP%ZBtA_9c!P`qqQrh9tA zaV7?i#yobf)yIy-`>cDJcP1=_JKu_UNI?eW#Bv08qhimkR0D_c5Dj>nt2GgEDJ0fIheJ5-sp_Bz>zSiWD!)IWprWQJ z4)#W0HU)PW$q5ZCjim=dF;(3$1y9b@)J_ELh6d7OO9RMIZVB{?A}{KUVDP^dK%<)28WFT>D4vQXDkz z{Jl0&szP#G9zmWuus?HwfNMLxg9ZqjKlB?iu&nxd zMbM%pVxQPhiju7FLeru~QB^E@jtA54`$HPwSlZ73H zj}^8TK8e>6M;|h*URB(|fFYcod7$(SDLiGRmH(=y&9D@aLM=0`hP}~gmF*PO9i3l~ z)}txBGSRyG5zQI4^$$2<5CV<(9{Dl;2A`)nNacPNRGwRj)N@cFB&0yCA0Q2==tY(z zkTB9ou<}^{aM_F2Z<~QkB9FdFi^8nDJTBQN-O|b&eI!4hEF3RsWvP}6vr6KYVR(kl z@B*@HsMe>SCh(G>3AVIk2d)>Qo?Qe5NUk?o?178vc9(G@tzO# zd>r!MMv#HBLcpX0{EGYbU^V%cKB`~nA*<`AS*>n%jIRV01JZ4LPP@9pS5mE~M47^_X2?$cO~Yfn`|Kx^{^s8|$I%(*Lz0!tbt|@QQR2QYtbI z@hxrMxw^^|*6JCLY%GMEMq4StGz&6_NHoqc7haBap4AT7(#&?*dlFW^8Lb0(xG@s~V)jrHowu!v;Q8TxP{idwH3)F*575EIU2R z%PY(HhYfy*WKO^Sb{sfho7cAXes$^Q>^B0{7pOWR2X`L#VdANdxEwj_~x+irA?sEk$uWC z@lnRu`@MOCKEhJ=<@EEA(^n9!XZ3_3`w$PW)H!&rRO6M_xL28!Ic$XDifj7b`onXcoNF1kkzWs3eZ_*EU&TBeLO4g|~`x4C*$mZl&Mo#8YW>@TNSHX6h?7^u9{w_-tFvPPLBS;<{qlmiIb0ydTJ(nJ2C#u%wK`sqzwm-Gh8F}eyksPpQYZx% zq_yBu;!OM%ypd)P!whsasB&ro27hAC+T}cOo!K zAg^QuK8vmrbt4x07o5S(lk z-itWU@dwdOG(>>knUPnKVX5bl>prsZa5wwS8qlggVa7$1h zAVU4GOb&=tB>>-nb;cK$kCax%v6eVQ+VW9Rb$sgk@vQ!FYG5u83`}>5sOQ9MNX23Z zM+zgr$to%~imwYw9+~F*V!TQKgf0YZAroi_doig|Y?Vr_Vnh1TWIT!0Cw^VGtYmyr z>d2ME`A7)d5oW>!N-$u23MhGmAUS1g4zh1Z*W0`?BS9ywKtz76^db&&=r07iY)5%U1U=qnVI zMw#>pjwTbJ0s2G=22GkuS2~Uc^P)RHw!X7D)s(hH#CSB3cWXvoue&HNln|q{lBwo% zby=z<=a7{yvv(-luNW0%U!3y`IsO`#dHVR4^Xj17iGSvt2`}h^l(ebAo}ewD=K7^*6=NlSa*iK2m;W(d#x)+ zK5vG@=I2MQ1RA(VPJ|+K-Tx%K5qRAUFzVf;NkV?+^OtKR;F&zClggNKuR1=XTWdji z{zR17!^*>K1G4f_>cF)nHaV5waCg`#BKZd^l0B#uCRA&wRxz zOiwwMh*j%}aK7_F_om*jo$b8|#CwmVg$jy(h7H#j7BCae=m^NeX;BSZihf)ASmV8h zCN-OqX1up?EPa~}z@X=fdV6@dU8nKuzz#1!z9D^uD77l0Bk&TaBi+NejG~_GJaTML zi@nLt-z9scX(ZQXAMCuPz-HJ=!s&c4Stumgtxlr7O3y>u@O=e%oX=tHcQpyFa+K@t z*57EA{AA6(H6JBsyKI3LJSDC0euPSgh$}~}M0)$Ri`w&WuJe{mExm8Zop6VvoqwP; z-=>O_?4?vw(ZHP*?B%>kheTub1E9+9pcEDt_Gk*Vr@cDd?uB zd+zOdH|!Dt?k$te1^=mfi=Qc*%wwcZ;g45#!g*S9Sd-WW$+U6MRkVC6&ixeVGR#ft zCf-mhrnh@1E+$t77sv?*iv~b5F|qLzxSd~%ss66te;5AP>U~FlvW$zv&7lcT!r z4p@<*Wey~wiEu0iMC>X4n=d_jUqX;#8on37`e{h!f?{YfNlmG`=E`A>GS$GtLNQd- zJXa*-x}lA%Te?<~3hzP`HL_-gWmQUbzA8=+$q}P!s%bl&OgItKPy$}Kigm36J)6Xu z+lw_vG>jZrgbERVF0ny?9Rt9`H{ACFqsS@V*3y+qkUW_!j{u|XJOmJSWci`_H~#t? z=O3Ci40SG#A|rd?1+qt0wfvl_o3roZWbAgn{W#wiuh1e9?TX^u$s6DJ#v4!06>(rI z2PEl$V&kJ#PN{C^+xPJ;tqJI=Jxh>A9>7 zd8bGpV8g$^cwx2YJjRNDe;2~$j22^r=snUue8!98rS+RPd=Hll*oL3;_wn_h5kI`} z1+@P^18E>>&Y$Z7I0{5rm?glBhJAAkL@JVXG1u`o=5=ja?4NagMA3Q=IkH`GVs?y4 z$;!))nw4Y?Wf+xExfP2af(AT-RoeLr-*WH)?2jN&_)t97Du)nU=hRYV^XSVeNr}10 zIbE@3=TH@-R@XZp_pJbA10X$zXjy?QiCagu>Xks2dG2v)G?(;=k=9|5qqj{7bk$9$ z%+lV@{ny8?8^3CNQbs_&Bt7NosLq0Kfg3z3-rKtC>X|5xq8Yu7$Z;Y;*sQL6dt_$& zE>R47DAR;bL9tyIxq^dB`CS-eUsmnmd~6E#V??&NRDOJlJ9a^JfI_mVsZejHTFu00 zf@+(LwVG2X&7;=wKTtu}rx#>^cxj^L`TxJNp`F;>)Q8!&TD_Wq3l2xTFuS^{w(-x; z`q1I?jeQy5bEoY@=>vRp%MdA%J@!9(rP8;Qko>KZ)GL;6spXc=HJ8e@ zo6GF}>g|m`mgPTg++GzcYW7(B+;aO&My=qx$6n9M2NS>bl1a~1ZqC|XJ#g}@L#B!lyLR27T$HN0+Tt0 z9t&#|GpdT@U^wBi9{*b@+wv0M{ly*Qs?7vdj9Ku+N0p(RY#Os;DkA(vPgpTYjJX5j;2^po7rG}!)WzcZlLDD%81ebO!~;q)Le>iEId~BX znB7WQ;8;5atb33JoLvKuI;u0Q+v8zlIFZbaSWJgWQ4F=peVQBnuI+}abIso|1gBXX zGauwx=4G0=qh#ckLs)Zn<__bU`wvvPyqpnkSyeBM#cx#xu?U zLB^^l28;R6!M(NoCB%Nm;opNd%q%V-a)rXLchNPUp+o#`VOoQI9sKQh^O@#;!_hho z$P=;8@M+w&ojp+O!y?y`=^E^0P5g5VSj=4eY2+Rr2V+EpE%+{((DGzavq`{$T>mAW zcQlOy5rjwN-kIGEm%2)033lNate|tq*&vi@GuVsGG06?H)sV}6x-+OK?9NXq%A;RZ zlrO#(<#3~+G$kX3v2LyYnh81>gIrvP9ER{QL`@U%gUiH3tBSB5s8xx$I#EX%HZTY` zUg6+GWfEE!WDGoFFj!K!CRn@k{rbZPL*H1+v$?aoKD|7bUy+^wG+i8v%l-sJ-s>8#BfEW(>PXb(`geA1*s89~qdb~stJV&0QJXlz9 zI~o4E`tKALGNvLeUE#HoC{JGz8;Dw&^wn0hkQf|k%w}gBBLiMBY8_2ytmt6uifKiZ zBARnQYu}GYUK5e1OB9)GWj>>aFyBH?ySgZ4ESZTCyAi5mDb^CkN=y25X)L!(6Lv)= zD@Tr0CL_B9ZC7rrG_5a{#)MX^B%$iHt?k{b+5bn2P@$SWTg~q_s|Ho>Gpb8!L^JZL z-N$p|QEl;6VV={hEAUJM>V&S4O$}i@Ya;@X4QgL~5@QBc*mwRV@^(3bB{?iNJTjCfuIG$@omJf`Lgr%!P;-?mSkW(&A0Rrz@E z!S1D_L;c&g_gfLMKVOU=*lk8suY`PRq2P;8-9|G%9P>Q)H23ilF8mX8w6Cz=!F&W( zyOk^ROwc9nr$LZBJ4?5BR%y#_ZOD=Rov$~Y_Pw{-`FNzb4w(FTc`|nF>DgN|?b+p2 zjOf5R52f17R0|Y56f`<0Ld0o0=obNbThD=_(<101wEz}mQjrnD_BttAQQ(z^>Tyz{ z;P2giiE|&JK%?+<0|`Qb2%IIGf0zxY+{&nGI)y}G5O14~C?bj?B35m8g-E#H{_~Kt zR(RDjPIFxa=rT8=Bc_vNDoFn7MU92k-_KA010-+sXHxnbT>EUuknG5)o;I2hYhq*j z+E9(CFbSIY+H|E23&+toF4RXO@eR$wc_>AGQ=B|<~NZ|fz66$jIu&!q=-HKoIXrbAVDDHp20irPo-HO;?0bA5^(O0imM zXeiZ>kZL61K<*8y$izkI&-(&Hv3Q|pVi4Z=Jm%qR?ECB;K<3l1v!hMbi8?qnEqZVO zG5~?DaypS1>oR$;0OZR97e@t}INM)4B09?86v9=0;|S{LwjOE$*EkXz5;?c-{0-Eb zsX@gNkJMHNlgl6v#ieQ0m|sTe`g|TWAeOBnXTC8*on9(RqOzQ%wvlbq*B?#T47wFT zy6ppfL-QcGt+lX)tO+DZ@Kz;)72mC#4D!b%6*isk7emSfQq!paG29#OM}y1z%V_W( z_)MyB+`*FkMXnQm>iu`#`TjdU$z8lwEAAeYF6f_AjVZ&}1F{8sm94xQI!`~)sXcDx z2VALR1>Ax^Ob!>^2|5_`3Sb_->_ql-NTprbNebEor_l|8p5Pjor7iR<;bEPxr(nX0 zf>hvZzMS6>BgWOljdA%;a}zD|4Ys?)kk#VuHZ3b83@gdxbTX-QIz$n;82XT#atzHe z7QvSdtg7TRYjayI8Fy)7m#r;@EPEp#8leBk;wo*L(x{yh!&BJ2w6z@=e~c@=2+3L zCbZScFWg0HLc1?nKwR@8e_G*}48VPD*|k@fCwdEgS0YE%3eT0X+?ej{;g_5Za%U{7 z`e)trJ_u2brlk&~pK+h%&+caiM76YHduyW--22NDUVx0col7*-*B{*f~UDV8g|G)%<+p2rsY@_$d(L%i~aM>(BKx0 zh$n{Rmql4LgPtN_=T6|F{ms6baYWZ|)@^wDE;fyrXe;0U*D}s3_a%mcV|L<$FMoTelFsPj0ZV~wX&yAP)85^#@wfN} z1!&OE^LItQ0pCP7KXf1e;``u~iQnPhL_c(Yz{|NU#76v89KG)WHc9k^6x+#2%Yz*W zE;szec(#TMJ?YwVvAK-@Y!$-$GO-VMQLHTSe?GtP>o8O?FLpr74zLW!1BBc9+1J)g z5)cx2Rd&KLFvxynk%vg5dW|$6&^SVJvEwBO;6Oz{T|YgBf-SGov*yPP!TgYs)W%N9 z0!t+Il-~J|j~n6UjQxi9%gNT*gekxn@hVz0tw$($z0r$@D<@gY+%K4xb1SK%wi zPGhlZ1$+_E4tbH>rmzVkV#%`j5gRk>mJy1;KsLO2`K@Sbn4fLs+JvGAQX>$%X=#v#L4FLzdH zz*pOJqP>eU6@uEfI;srt?oUU{2kcw>)!Rw&8c0 ze~FI7Sxu5~elHz&uV!qAgVlUECdA_?_D0{)fg^P+%S8|V*c@lx^sIU5DP6J0@VHf0d zx^ooy*%evQZw+g+;n&uO(|IC1pg524;a)0sVoomE`4w2JWmp={9nV@>&FQUa$@!3NJ(%b`Q*_b;W?NFD zQF)h|b)p6g*sRSPc=}20sm^)FNDXKY3Sm7ePQ3;g!hEzAcA`@e$&mL8a#?X#IM>dWm;+wWTOc9$WuCL318Ik?Y^sqAJCiM$|#n#n@Z z$|c6r3MyqQIRJ_FOfq5xG*D>sXExk32cM1g-tpB!q>v_(3y&R6wy;i6UYZgU|1WOgi`4SrE_5`2aF+Wk@hht8EzEdQ5XkbdvM59q1yrMsr9<<$9dY|EZH+5}1 zWK{7W>xwdw+-XG<1K&uM@^-AaBV@^Iy?w$l^8HYgG0M!yX!)!eHye|#ozOnx#L7p; z?BT>Q*c|eb(D~sbQNup*WTx+MubvMb;?L37efkh-JJHkAyZQNTSvS;42$Nu8t*QEC zWhxLx+23z^W)Cw#lnx%H<@3{pls7zXyXq(&-iOcpE`P$nqSu}dGL0zm0j!^G-Y|@_ zhMvYVs!#c(z{i-*Ll#~ECqfa1Do>n>;V1c(oQx+a{uC?1tD?9qU)*^CBnmXu*ULfM zy1oy?Ot>{C2!r`&e?;_2fMTc{792pVXTM5KjgMY4c+F@$U0b4~=|+3C-9L4+SIgD8 zO757bhHluXPW8YtnmciPqLwCoZmBU11Zu+E8`$7TkBnj@uO<6Cw_Op?jTMa02{1OO zioDIdS`c~xX~%~NxcwAkJ08fYH`iJLHF2+*n@Fi~$+;oy zfQG66kGeO3Z|th?#dYr0)xB4{bhTNQZOO7FZ)3}rWqCZC;+f3mB;!mbdoocHvJOoq zVQDr%Ss*C{7(xnxLNO0`>@a=ov?WeTA-onk{nLdO3htlB`&0VwzeCIG@*v9l{(k3P zNtQh`NlQQf_Y%+5x%;{2{LXJ*kjgw1YQ8;(>P1n@t%nNWbNzvX`LT#)_Ib=yk0-sW zCw`|7$TaMG*Y;@WQpNKz@DdWSe12CyO!7B}JgDK!4=}XX`*ZOAhS;nHJ$fMN?x~DK z;vP?Q!I$lJ;F?JPsH)-;0rj@02X4Ju3(a2(y zySzmx{~XC418psCO9F{YqXBh`FubH=ttYffW1;^<3D7O-6~J4mud7={C9S#-e8mV3 zrgaF?!l&=8)%NN>0qI#!4+dcb4dy(G2SnR1tX&l_ckuX#Q9OqkLkm7!bGs~sGQD|( zocH`K-p^GXMFt2IW2;r^~OMdFyMML{MI!4CtNyEy}`_i5U@3zjT zX_fU)6EC9B7yCHUI(`Og_)6FHu=3pHdcgGvargkkSOWR&U}7OT5R(lUV?Vu8J8N>w z9uS+3C+7%3O7n6eSuM>Hk|%zX*q}sm$QJ(3 zmTtFrl$?Z+3pvAxeclWtpps1l%z56=cmfKzB66SsH_Lozdm9epfZ^`r6Jo^USF1up zUeNI1mdT5I{6>8D$gYWSz+c7VgXHLh+*_tTDMT{l@6D0kaWIYig+ffy@jh~l8sk@N z;%##XMF4S;JM5X^OMoP8UF)O38_bcw$;Z`piUp>Wi?R$R46-Ux4}z9p z`ISkmN)Frnh{FNH%}WY-Agm~q;~z}*y@FmpH`IJ>)H1SWY`~B5q)=cdtJ?y36;Y`A z*!xWV{7Dh;4F=5SCsByN8tmEaW*Sd4^aU4fm4LSg2ohAL)E}-zeW9Mz_C9a28r_G{ z(}R~RyS*0~+T;!+Z3dpmT7%_5N&K6SfV$!j6vFW*bmAnhLgjH(WB{l*v5}kpYAp;% zN-vUgScrw@i@{F99#9fjs>Ybuj`~D$k^j34j~S?SaSN(Hm+xR3+0hMKp$};&&Jxgu zN5_4OlP|a?7xR5k@MdBsvi?I_ZFCst5}G!YY&L55?O7o0?+Y&4?q@?A)Z_bKK>a~?-gZbVJWIZQWp#@!94$>ZJXF&)k z=cEGk&FLfwy+f+Qrvne~LUa(;m#&eV2fF#My1qIsf=X!>jS`XxmsYBXkvm`eviym~ zwDUb)t@(5L`whCHbtyICbh;XQzx|`d=)S*}KLo=H^hB#HC$rxKFK{_E%+)0kp}|xf zJQD$~ZJ1aiU<@NfBzHJl%+5#q;}c@FZI<}O1D_t*mzYcR_fFi}XY-AC#|pfwI1&VN zN~%O)A<}>1MP;^EVs@#GAXNY3{(1cKi(CEv=2Q61V*kJS@i*^p;2-|bck*?ReJ5Q8 za*RNBC%^)`LKqG!j&U5scZWr#Md#6&al@N%O~S@w;&?baHIjgBaW!nY6C+bu))Weo zo|}JRP;`PZ_sYYEUwK%ZcyHL6Ig;KNN~c5n(nn^j@Oybd@g~KwKBsuGux|coMMG_y zIA)%0F*3C48@JnAk*R|2!a+xhocz5&hQ-hK9fELlV4UGaYfeX{)+0+|JzAI zQOyQzPkv8P{3^(;So1W$Qb$IDM!haGX9{BZt#rVzY`?||tkKojT(Zp%#-pJ$KP**U za4)5+2*%y)ANe(wA*tpAY!gm_2TJ$AI2te_-u!vmT#B%g1dvim7oOml4S|~w(R0JZu?o!uVoDQTlbYM}j zyq2QI4O0yv(gHCTA=N}>vCR)l-xwb2Alcp_pMppbP{Hb!oP&2M>Au?$J9}pL!y)pz z16#*>y+ann$AxS3k=zo>oV>a8`1o~?K6>5U%AU$Xw)yq+l||FH`&CA1zIPtVM1|@7 zdB8IxBt_4m002@o5W80`;onX-HgHjZ&6VaOD29m@*J(b3ZfqbkPr;6{Q=`AR>ffST z6kI8WOyM1>D(7c3BRjNvv=`RCfH~YhX(bB7X)mIXMTM^r;`|I0Z^OGsx-apVN%^Qo zE`gLXxjd<*;v#u46rC%SFs~kSI1|;q>ES}$idtXjI9;4-pYAE}>70BB;Z;e0SMj|G z=&=_7!%#OOKZ}F^$=ecop*Ry`gaM z8S@~z!>X!GVg=LyXD*?Q-( zePR)> z{@FhbSYvAe%l;8ht6pEo>+?@~bGQC7Qr#i4WG4^U#+Pr15rNSdU1m7c3M4IbJZ-F?2+#gxF0U zsS1e>(rU6#k^oMpNSA{jfT=?Y`5DPm)JNEJGRb7d3Y2(nWS*S>kI?-iaMk1Qm>(IYj0qHZ5F8$v|3<6DXE(pxo(J|rjsvP){0#sv$rfUy0jo2K zqHXVDxWk9B*IIEwh_!r=8=T-;z;gHG-~6>VFW>lPK60Cx8LC0Z7p~snRhpI6;a=1R zIeK)F_d}5nnKM~1=XfE@sWspx7~Q00C-0d2BJ}$AH{YM{i-($zh2jW8TRpZjH6TS~ z;TlNogie%lOuBG?NJ7#u#aT(69TlFOgTn*ZS}deQ5_B4x6{5`Kh(_1pA34B^aw@)~ z^qs-_V5#7l*|B&`y>Ne_l$3L!n46RaT?X3l~zl-Uo{}`DoApi-ujpjBEwlfqmPYcgxQ`;#dWoUlJxr zR8QEy1&0kkP0-Q=@bI+C-1UFQ9LqoQ_LIDQ1#cyQ1Mrvmrc+45Gq88a~N2}df9m%Ca%06iCL0{e`ST2-ir3%Rezz|v}TQ; z5rX#>94DcHVuU3jDi?F)BjkdK=Xi!N7a+GKaSa^H?8nzW$oh~^&|AQbBcOPv;Xj^< z@yk|X53t}aUWu395zNGbz7oBfePZa{E67DdK2|dSGmWI^Isv8l07;s>)KG=h*gF1?03m^T5m#L7H(90Uq`xsTXV+|;O71j{2G2lu{Q}fR%vtcaBoqVEET)y9~FfIhKDQG@xB-k##2Wq9IfEWMTrdMT44RDd~;u zZS{3Y)l)>_CGJ}VypW$DXb>h{`hXFK2fR(#k2s^V4YpuEf)lS%-I`u!ACBz$F=g+L zwRGXAqH9Os;*m`ke!Ok~0k=#G8E%O`bL?N!Yyrc>NgLSNdcmeEQHAZwfNutDSEAn4 z3u%i&$fgQw)WU(w?&$7=#(0>}u{r{J?^5&F#70@F_~rn9$trQ`;|B)PSXg`a~acS6HDN2VQ;3 ztp&Up*yr_>Tit(qR4ghezka{2ic^T}a!(k|pLn%8ECyQ-Aj7|aBMAPJwOJ(H7>2(I z_!CE%C36j!fAU0GM8-k;0IbTB!p?2awkBI8i~j2%SIEh9mM>B6U-u<}MY(%bKKdgeUJug{ z>0v)7Kfrfk*5f&}`*`4gzLf86-n%HDa+TpO3-JgHGBjowI?@39F3RfqMZ53Xdd=B~ z#qBcSqxvt1ag@GgM*yABR{^eIn4?lSm>GMwP;@HCSa5rSp*Z10Srffa60kA=Cz4f| zg}Di{AE8%(_Y)AP&EVjTUT=RQjYSOyU`O)XsT@Nua)I&zmhvv^pkLbjmj&}2QA{7BH-JWc*B`{8DEtF!Zw+Ip0}Rj(sp71aqx=u&~C zLxwll_$YLq|M{JLz*pMC8UI8U@R z3%PO)JYX;82J%ctZWJOy_(w>9aDW3zVr>F7@U|AIu3fv;l!-t^w)+Gw`4Q*;wDm~j zi_9@g;4=Y4B#R#JH3ZrpF?TUs0Z~3X~79mSaWwTW3 z1SMd&6$vd~_ovJhH@y16n{GO&dl#enwwvlie~Fdr#E)3YpfboGvSoFqJM z32f1ujlHNWEnj?Sd8x8^UHhH)Emi8J<>gYnvUDGClGH6JHN1n?0wV9$i^;@sTk8a8 zIs&so9PXQOo5Lc}TFH_eu|}KaEQ|AYyLn=Dbro$Ecf^XFWNZc6-{xA!jwmhH)@l%S zPGFg$UmjP@_TTISb>9x1_2tN0O0qya7PkjtO3X@C4A2Utm@b20R!HT-RDrI7x!03# zAHaN~MiEW}*HV&8x*a=8AqnRX?C_PK1t)cLPL25g1Yt*Wral-@^!@eyx)PXlXAlGd zYl#V&^H2`*u@#}cl`#H;(OS)#pUOqxW~Ev&eJN@Vd-~ikth?86Nc_x)#Kb>?1Ciq(=8kA26H7|}KODEbNQ`P5Dlr!x1u3W2tm6TKT69d%L2ZfSrH|RbW~(9wEe%(|36CeCJlp41*~^EOC#@yBc)T6i3sFifjU$vIbCm7v+=5t#;6=mgh*iC36~JtvtI-y#R?aMa zVE`z01UTavvhWOJIbaQgu(ARt9wS94>){}caGSmEogMO`>Ld9~tSwId`DAVY)f>Z+ z;6NY_Yqp5>hU0;vH!$Q6KYYpLBF=^mCcU8x$ea^J&Xtf4nXI3SWkg@>Yle~ag@DZh z#*C?AmRK&J4REzZxNMj!$yOmC5q7~?RzVWG##F2|Ym1dp_9PyyEJC1kf=O0twQbiY z4j-o@#}6m2{|#PHyrFl2<*@ZAD&#f9Ay|kH<`PJD#0*|exRc(DuPqW|F%Ih}<%Gf? z)(PTBDUDACWH3>lslt4SA5yfeh-+0iSi|!V4%o++56)iM7p#1kWI0t!g#$jADUkg- z6U#&)%Ns_`Fk%_hf=AM<8{r&g)zXF9Z*9G7`S{dm{HY2ge}tna5*}PniE42bt}d5V zx>b&u{T)5zL(X-^HvfXNh|uEsjUE0%o0^jB^vMlf+tMvkF@0ELy<#8Bza&*i=yJ4z z9ZEy$DN9R$ybiG)CJf6t8~yPBr{El&O|TuV@O*Zq8OFd!xr z<*5&%>>NlwCL3&msef=9=hXpCeO6JPRkZ=S{1nT;wyt3nGCE=m&d>`|9&~uH6#)T` z5*n+6^qU=Df=O4lok^8&6MG-5Ni{@+`$rqJg!@l6)g@dtOBXzLzN3QRn|K3dgiV7< zSCS>&nxiSPHLe?bc9supX-B%cCI0D5AJf*0tn1YNO~1pAb>NsS%DcZ4H0d(e-LBWV z-Uuz3t=>aX^>{0ur^Muif;Z+6!jHpAQpRyuL<ufkNrc>w#nflV!jxqg3)u<~^+h_Uulh&)t-n$l70Z!! z-Tql_oj{`j6Yl)-6^oO?%494txrj{V!aKHIq~oAip!PMQ`=H)`q0jOoALs2GI-ZyI z{H)`Q3sIDWuNvSxlE95qh<>}=b))Nk*Q2htyFTsuZP(W&T>}G7pul<@d(7hMnFb3( zb%x<8pJ}j&pZJe{hj6u6BOdxj=g_8|0q1eQs`Hezhpf@fFYUfMw6H658zl>?QKKm* zL~a|M^Spjuz#RM=?<5*TaUWlSU*xl>UvcnF#EUunQb(k+TFk+sAp^Cg7E}n-sT7~MQMhh2M^KiQB?H6UcIlSe=D%(~`rUu>S!pS9MXVUYW_d+_=V9b!riDW+#wUN4=SIjJojX*BRIdSpW zE@*W{J{-Pzz+bFt5Eh$A?4TGI&sEaeKDSS`jGq2*I4^+rc8wJle1l?TDlf2`%|3K< zpf(zeC4-f{cU-g2jXLyu)7dN@C-PH;zPyyBc^&tH0*zwUq1OS*qL5S|Gx&>8hK!aM z+Awk=f_2QHni*PM9OX7AJ@t<}MBkEu6vZy^Vk@yst=XW2MPMRbq?`ZySYSsmD%Lv9 z*T($G=1JB!H@`;_WA!!<>5E4~)L-yqHSlC!*cggrO^4MAS?$@vCzaPs3=`=69F!F3 zJx~yo+C^w^i5@YQwr$v$L^5W9zjp}8sqIG~294we@?Rk$0N%A(kjBQ5?Wd8dWBWDO zk$l@1j%+^_uue~0kUCZ4P=pfBplig(Jq@W~6@6n*Humx-PhyJx1NT9hgFNs_hv1Tg z-U#c8ogF9}1!-HzZH4tL<(a}0lm zO&8y+s496+?7j1BmJ3pOi`1F08+hv_m_%;`)Z@P$y{M)?fht}Fu5m^*m}`m z(m&p(K!cCV0z;5gpb>rHg)aaWzX8la-g2|xDK3O()(z+v#UpZB64KAZ&j`FFC4Y*c zCy2xaPpKz8j_Jku)u(M6Oo6y0wxJ?`v`UUj_$bF#=jay2+o2hrf7=_!@!uQYHV<{~ zj!*4aSlIEY_uX>K6SJ>*&FmtQLkG;QysdetZHkvwD(;XLpMkSUs5Ux!*En)Cn!X{{ zHf~91L2Wv@?Yv#@*>&Ewl!R{Hh4y`Q(}pW@mD`STg^RoGqNr-^Wt9J`C5FFPqC3~8*B zSfz<%i4<3u!X(->MG3tOxt;KJQ7v0#75|Ih(FD+(=7aH0?+J)dK-YslJr>Do!WY!_ zKu837ba1qbhbQ1Q_G6@!oH(4>l6ZAu3p|vNGZcv3iy$1I8i>0UBks{cVF9yJ+)uGk zKChwdZ+@2&PKseTbpS&;vLo?g6!~}1<=Q3^Wl6@^iUxE(M@2P&wxKxbL**Fh>I)SR zM8;%Z`cFXQCPL3fL0IYSNof4__^e!}Kyaw{2oJOXyd-i1*j;@lr+&aO#auWrSXqMC zO7t-{3ub9u19=}$7gnG=LRP0sklINvt&CT~;X9;3;=(9-xl{%bAVv4^+J2g2@wX6O zm;PtyQc*G>bJZdeXaiC(@CB-kRpi{!B;$f|YLIxfm;r4 z&1dV`{MLip`aJ`lG$k!5^c&4T+%}<46$gsoh6g+^JO98f`2ppY13Qj}_GYttLq~TU znziz|3Fs1L2gsj&a6VAHXkwQ;nVMQorWY~nHZCIlUI1R}E{o6&3+N_{@fVG|gs0`z78Q$5Z@M(Q(W%*jm>LN9Sn3Xg2^J2J3 zho-%T)^B)J#P=1=-!;j}xX`ZVOzRiI&m2a|}P{-;0` z0-x|0l+deng6)N?(8W9h9;HH@DYREiL)n#pR^=c~Jb|+0QARlA{0O2X=l9HGI}U+PRF<>~A_Z@e>g~(lNlvhu=cAraLshJuk2uxUfK}P6 zL0wG6vfTs2!vpS|SORcAWf*U}MXJ>=&(ebrxW_UqLVG zQ)RL})9J#YBDPsX^`X3SC|+1Dq^Em|hYE4nb+OWk$_7^JbyD@#c@YP!A1FaEK1vw5 zj*;&;tbhdyD#AxQmgT#F9o~3T;(D!Z8a<9IAsy@3Htf^9k%fcRQ&`pQWut><0|3)J zdN7-@1j6fwzV*bww0BZfsa&6`PI{*Y7LN{PLWr!}uKPsuk8gZ42+e($VG)V@aPo<7 zwNHjJLq``o?`}T!<{N3XU3MStp*2jkkY%0+it8A4A%AbRW!aV1-0pp_8BCiCv$Tu= zPFQc~H8PF?0>JD>mD@CF{EpFhyi-1AE zzu@C2lL*M-LkFb&SUn6}zD3gxE4=@ETMmvxDd<;tVFjodmCp1#8Q*vRi@M%wqlF<{+d3`tCOWAq%BrYcWNDwSDque2g8(^FxMr^t@aT5+@8Mnb-NiR=sk^%H$OnOy=jdRs)es zD~I}r_0bX>{CweycJ^jeFOvM;Hu8w5PZVy?UE@&)(x}c7-7>f(6zI*ZFc-9P-hbKt z8eN2k7RQUFp5%0KVPEr8V-HPLw-l;-dj0*W9al;-FDI9e_rEx08&D+47N7yJVG5Z&Sf$a-Ocrw1nm*%E+82pQ~O7hlTLi7%r6DYpRT z5A%iVY1jE*k5gCz=Ckm=XWtf7Vxi_@D5eCj=Kdbw?YofZe9(d>*$9V<30)6IdV9iw zux`YQp|GK8?iiKna*L4%-|^sx5U#h}7VhZ@-*y-n%nVj}TN{o75$m2*fl_`bYA77= zAU&U|2sPj@q&zxsb#zz+LVK2%_k;-Vd5I@y-Gl=E+`tzHA^%?ljp&t#Q*;Tv7zdgw zmJxbYf$SmGY&Kzn6BKG#WV?8N8EzzfZpG%`d}I8our6LyFqltNM-kjE6Fh&ig&x2Ywoif>QnPy^+CS zHoG&M6_x(}+xz=xGb#Rk6CSoYS`GTc$dxc_Y9Vn!`n5{u*@o{g8Xf@>7I140H^-#!eawzqEYZRxq?FHdSicPoHJa#*>)GXW%nBK=!kwnzKccD&_LG6&<8m zC1;gED-v=2$`2q{g7IoPa!?OK2@sef7aEQCV*>KD_yuvl)M zgo*2!v-b{w?&^mg`v%Y%M7xoWj>rn@=cHT*GR}+_t?mkrv&~fM<(}Dol_`A2Tq^bG zco?Ew6%mmI|J#vuIBkt9MlLmH_=I`Y_UdK9Euyh=O81vaA8PnRJ)w8r`vefeuZ+Rx zW;c?tP3^3VBHl*w`n)Fl;L!kEz9ltr5Ne>4n0nSugi#OV)vZu z3VBhhEMYM>0@hL`Nqzf=pn2DC-nBT%lmOT~o0~$3pA+dq3DP189Yu%hOBEuym8Ec) zOw5Pwy6aGQa*^m*U@8}epF7!Ip+`~*J0Ox)!m&)YL(fU^*w-OTziW=V0UG7Fd_%JA zT;EkGD@pOP0>B`nDwif1pmZj?1k6KI(BA?3vk{0*M08b|ONAAR9eGrU#RY-D_Ez?W zeIMlB+n2I)2krWnrDy``rbN{5>+R1CWQKxpVu0(UZY9E+7Ry+=8@nPK7|OhfB<4V1 zavB<31*rk}alus*q;G$@a#3ZB?7CyqOUA`o9|{ZSl2)H~Sh&6ZNo}HU40%aCVWA6m zPZTEJOsv<9ov_EF21fcOG;tWBu`-CnA#Nqzmm7nAO^zGSJ;j7AK)*nKaP8TzCNX@N zVX~95av_FvCLMt{IMor9L$N~y5MtNd(qO9j+0@|D+;x@Pm*SUtQ{GGCAA$c}m>eGE z&j{Rn8p=g;slmb2+(pWDcP>@#NElhSJ8LBFsJyr1N-c41n2HvMZ9j+x{Eu{a#0;`$ z&=JhFRBQkQCG>$mgrvwlx0enF0=16JI0cKb@H+Yr_~`hy`&iSl0_^}C+k)r)G?v*j zW#;nnxc4*tBidpB*%)hSG0k(`6(zLB8jrn;lYDc1xs-|ZP3;MLGUf0>9!yNSkmV^| z9|2_Jh^HCf_rre-;ufh2C7)Hvs}PzH`UqJB!Ft=^g^6rk(mEUxtw`9FEcmGG+IbMW z0a0~NB`+QvnFr_eAXHJ|pD;^OjSdsDG#veC^X~%4KnSJGQ-hO8FI0oeumAk!DdsxR z9l(bGA3-_OrC%tb%e(4kQfxUE8hY7MD#>++h%IIdNZJEb;41M%C<@yK-Z8Uf_K`0= zT;B5R>#x1`^+%8K5H<))<))y=%$R00NGKo}H3tT+pQri?^VbigfG3VV@+=RWh8|Ak z=#E%M_S3OfS{Stj;_bkESK+w|-j3myW8gLPlf3|nq(iY;SvJH#(qpF!h+#Uz)UV_5(7L^B zJS47pPY5<*5EA?%3h09!yR6rhvNPdS%0Y}#wV>LjH3kzA4cVNBe_J9oAe0CySP_ga zu5*~`UF5$7mXUxVZr4Js7kZxv&5K({=0i{P{`r?5-ZCO(&XH|POWQ`IKsmDI+bjib z4Ve0bZUzvziBxf`>3b2G1>H*~d2hP*@UIQialjm^5!#*sqd)PVw z`CW%!@>fYyll?&yzRAXr_1xHt`#P~~>igLgvY-0cYQES{ya}Xm{hjm>iPt;l4%(-e zICC1U0h%x%#RSz+X|bAg_(gtQSqpl3U0-y4tMy_(Eu+KZmWSrvR?F6xk}kXqUH(3b z$KPZ{vjtNU`y>FBT1X(kWSm-5$w;mpXc^@aN-FntFX>s;FIH$$n!kRD;L2#ff63jn zKhS)gP%deHoZ9s7noGRh3wi_=5-h4(=XTvr8)wyQ+&Yao1lKUz)p&{93!8dRNQm-9 z5TDg$laSYOBlBx8lF}LYC(tqj^{CfsU_Cy|suSMSQ-+c19aanJxM9SHx4H$a;H*C& zTgxi22hp!Ra4`VFD#yrj$HO|C}u}-U*`6YygQ0 zB@~J6pU2{!-ye&Flpk)y5Vb-&eH{7pVGJ^b8cOY+Sh&m=r*Dq?E?bz`okF0zL=JTa zIn)_BaBNts|5!CIw)arLV_CG8%%&*etY7hJQy_;#h zY`ehmIW%5}+@Skc^5i(Fu!<8qE*Yp)&d|x{y*mVNPuzH*|A5z`FEpU-$JSu_xf)0 zZ6(OjR1b7b6awV-N-X5_x@CZo+oMWb)LAciJx`fZdI=A6BmOF|>HFlv&IBViDBL{l z_fE%kxIA3p{TQRnm)PZvUsY%LS@>l>?3ptFEb~cg;Mu9xRA-&<&S=cC25W2)|8%Mi zkAGr?F)+iwK>f$n2K|RE8D?beA966O$%735IpiHDrk`vXQ&`Q1AnD$TtSN7Gy$@b_ zPrLre^#j-6yIv4b0k$ZcTnG8GL+R{&TTkRLk!7vxyiI3X=ew_0yRNrWqstWoOy9kK zuJ^Z?S-XGwzw`OLawWwf3ST}&;0%(?{;_te-a1CEm-a!y7jE^LbhX{2BCpQP9f#}fqwQ9$-R!)rwgkfA;)4$IDjN z`?}wsbk2iKkvFxdf-PR5Fr#ygd^fVS*)+!9Z#~1_XB5hl(4J#gtJwi__13{gtJMLA z?St9YX>Oh50Y6CxW%GFJJi>Jyi0-(lJ<)6JX2(2t9PMbfFYTOXk!>IEc*H6n?dX$i z%ENrF91DnrP_x0WT68 zW*2rqPsf#;H=AqiCx}M7eXf>7$0P{CQhjE;)p0r(jg;LS9=XlC!r$#2d}=p7&qKq%jxXruGXX#A_-+Av!8`^q;V^)Icsdv+H7vy$2Pm8gJ5M_4?KIT{g@7>Ypo3ko3%RNq{%X!i&^`~TbVV%_+=d-YV;f1k5@THj;Kt1WM))2#*4 zp5S(?0~59nZq~}S9@B2EY;vsDI@WHr@7Szm+h6K*i>x$5Fs&+g~~aj|F6hWjhE>{KCaa_T?@zaGW6j z4?38MAzzEtzc zT$A)gtmgNvh(OvLSn99r*ov?X+cyM|Uj#eLrv53fVfFPyLy4Z4iew&2Fc67_L2qd6 zbhFOpC+m{j!~{|#<&jhr&+OqX5@ez6g2$g@@5sUOzm;ne-S2wX^@QvF@B^e!2yz9$ zfB=dpNFDYk8U3hx`jnVuC@kTZp30}`BjW;Aw>YFkLChAGX#4$jmuw@Z<;H6Vs{ zu5>!6FDN_lVAv#M34{SkxBh;0LHwEiO(FyEKC#MK)q}Y5gfSMDq0_a_2x{3D-weeP zVLu`ipNn7{hMh%=`pN@$50rh;=0E%Y((miLyKho4$;hhoBm#(0@&y8k9=N0%Nb8je z!#6;jqG@`_9Mka7czRl7a^f&Yv^W2-Be4B(Zhykl7tLfMqy9+5pU=~FG?K|g`#k>X zOg^8PjtvcEo)W0*t@?TbcwZvWLrHkyo7-W78pR5$K?8rc>ptR);57wa{FgC&!|)|< z557Iea)#Nw7%vAAM#1BbqK$SOrgAL?F&az+tmjZ;Ef{@?gV_A9(X6rC3M2!T&mXz@ z$(x5Vrsv|lo-cDI$XhS=pfu?ta?}dE;xV^Ypzz{G?L#xMB43}4_=UT7XsGu_PtOe= z^MrYb-P;bHFgocn0z&R@f4cjEe=GY-_1IVzZEBE_i z@#nu5k0D^%SMc?x!r@dn8@i-VAbT02RwjX3OJCownWh%M!mGvtDP&g-`c>oBqj{g( zAIaw<;&;N~6Sym!LN>yPxAH4=qww$UCvP(o^kHnS5{#+3utJ(75c{PB>Ix4i2t^nWId~>l z^ZI}&iXqNEa0eOLcMPc!AL#MIzR|}=z0FS}v_Ob?Gjt%?<5tz)VNdG3thZp?++6L^ zKVKZ|dvz&j+gO`F@J8wcS%*d%UL>q=Yu_j)k3_!cN%<5}Lw<2Z1WNt)4$VcB$hQtA zzP%+|OYfH7MR9o)r!$W10hc2Kh>Xo6l1#xuSbvxmB=fJbUl`NoL5leWvcfr)9Y5?~ zK72i{g@ys!#&oJ@!e^Kd^INxW60_m?v3X@*?r(A-M5(2(*Z;qfC1QZ#U5niLUR4U#zSlC zAFEF*j|{=>nmj|D*Ec`ZedL`RZaQ(cceGwh@C07Yl|?R_>tRzK;97)|8k1KL)<<~; zG#}#xSWWbkMC_uOoIK>6d$nAj%Iow8(euAK|9H*c{kqvq|58@5lpmu{-T!tV8 z^QYcGz>5Q(sgf1gf`VXrIH-6^IWtfQn7NWi34*!NC$|Kw()GRby<(9GL^vF@f&%{{ z;nz;q0@jxE$g3u_kjt57F1HW@eHQ0!K?vbwA3X4ox7&_Qp&Zs!`{e*t&LR%AGxva# zv3QWyJjk8n;fi`7R9%6xZOG3=`45U%p>%O_9U+|l99K}0VrgbCq#xo>IKiaoGD2EM z9*g^sKFkPv6k*0Y+BcycQ8ymvB*eV9#0s)$@ss$W%?4w^S4_tIQ9t5Dh2im-u`Th= zy|bJH3~U^$DazsTqQ^?Evz8|=xNLAK;|OFr`v}~?sXUL9&ovy!5n@o#P}vHJJD}wqZmNr$%=n#w z@Xy7G$;H`cb>rvt`p*shPOnx`)r#i*QoT;&AmdOet}Y4LjPukF!AH-LYB=c>P$OJO zJyrC8Qy^L`zaXJ>JnfveeB8g(z=eFKqxqW4%Ub)vuloHomGbvVW32Gom zK^?gPi0Lxzj<~(~aYaQMi%JFlOo(~AG;#VtpcJW}P@+qfY-K4*X&$pATG2-Oze>8L zf+8p3E#ndx*JPy}x4Q5Q#d-0(lXML6n?!Jyg^uL7Y`wmUDC&Ca z>?$guJ7?*4z1*MNxMrye7`<;@AomKgaZ(OQ`;~a;3oaa5B^Uxt0MA6U*aFU!s|f(4 zw`i@NN2U#Y9cP;)*{!H@w#j5QpA^TpA340>9xl(1pqBG&=%7N`CoTUPisojmz(47K zsf*Pk+qYeUq`pEg6D2}xWRXU4TL|v6*$4aa*xKvo*$}m z$2gj52Ii|s**?gDl6L9>DqmFs?T#ZLin*F3$T6zIUQjm*bpXJbvc zkO7D>XAoaq?^hbGHSiRihqI*!1T&%F!=7KE&rzUmJ2gNp+Tb3|u6Oq=s#Z|VhyAXb z!2@#mJJ>NmADA|BuAJPw@)%c7^!HEni+38j`x-Y&fE&purRd9L;~1i9Fh=GJ&vQ({v%r_5;Abv&y&9PZ-{5+O>o?(f zzX9SM=&+SSh(-y^jEk49?;!x5E)0GVmvjN0;R)j>ru9UO20mum3k!f zu&JG4teYRktCNF8Jgb*pgXi`dseztdNj=&_J(CxDQ-&w#{SC^5|54f1 zk7_6Q0hlv4uh$0fX$TVNBJ^umvlu*;P!9t7ERZf9O+HXmX{`cgDN}d(jI=NbVkq8k zh?9yZ!rMBC+-*eUjN2sXyXYdfPdp^s!A4M$L3zbxwX9e*gwhOa*oxB{21Hewf15~% zIFrEVp7S;Tlv~5Yk{HGliH}`k+m}Ea$OpC4X7@U*^z|5cgOwKaaGe#o+ltU_74BN* z8$9c}&r?b`8@^W0+QLY^sOwhRP||2Og{$4}INa&b*m1bi1*qdN__XKkSSvY>G8&e~ zr-dwS8r9mj>0fIf$|?{&d!Yefh`8C@q(`|MQ3jsoduS_Owt4$#spK<^Ww>lF8-}lQ zXRZB(EA6ITXNwOkuf_CbOz@fUrKvWSf5WsbE)k*~t6b%p*Z8ChWTuGh7^i6b{zjVKNYv-r#HjEo(1$j^ z8|{e(x4;QMgW~Y2ik$jtFoG{T!(p*Y4&``2Trg~p%>Lq;hXxtJ($P>#3#f=Vatp<$ zqDGt#ao$GwOeeNFV^4hBfJ-Pf@bppSRwVv}xi3kJ*pQ%+=mmyMtP&5NogY~pnTOEu z7^UsYiU$#m!5NPP0<%`(V2_DWU-(yhy#!?K;o%udg@VOE=`$z?xwJOLVUw`aM=}sq zz;e)wv92sE7rZvF90|F#v|%(|Ib_LE)X&npuPDNjPy@$S6k)qhb?`svPO{HbvngGm zmq@-)E1H4bG%>paX0e8jj?9Z}1APRZXTF(ZoaLH!IM&vR?Z#M;S}+PuP7-F4 zo`B!kd&3QT!?d}zJRBY_^M;B(gJ$ujal_scoyO)nB|1+V)(@^??1|P4LH=7rjaOH- zi#aZm>?ui%ltpRux;rNoJ+klRc*Wr)_`XE^XIGd$pGljgS|;{~vEY$lt)`o6)VT(2 z!Z=JhPHXL)n5#ZBMR#GddSWG(!9B4HaK6^@RjbQ&60YjYC+yEOb>??}muqInDKmvl zXOIRY1___B3cdmmAiE}M49?kneXB(_Hqk@BDU?_&q6$N#4P7WU)>s6)OT&G9*gaCFKer zMjt>=ItJw$3by?sZQj#puC6uLdWXUN$eLO;Q9~f^b97S7hvH!KkpxgQ*6Lip>@>|n zDa&KOX8Y(+b*1&VyG+rxm;+Lc7m%vT-htT8>es3uE;iQ0T;rX~=3XS<(gv3D1x@t3 zy^1)`|FL_Z^ImCPg1K*gQz%|{zt9T#r2!3zy7rnO)ql^&{Eoes#v%+|!3dzc2Al5{ zoM%S%3w0-1uQInT(KDCak`~K#S+y}^J*O z{6U`M-_Gqv2r%3cK$$O-b1W@pnF)fPOoRs>S#AgT|<*Jo1 zjLAbr9JV-urQwt_v`NH;%eMcoQoxb|if-?(V3^Vc0Gr&db)0B958$5FMq{mzZcy<# z+P25l#rV=f=AsP{ZlN!h(-+4I9+!m#rCY@5wZ@6u)H1`k3pF6Jj=u!CPc+xo)3uqy%bOgt5U4}^ z?hhD`#F2OL5L2a2=9Lwbv<4+5U)O((NyoR6K?Yyhp&XPVJ_$r8JJB{nDj?XmgM(H9 z>UL`YGEt9TMapZpuLsQHHn(rg=bp`m>oHWU)9QMRv_2rjry8an4SM(TlRBDz%aTzb zz>?95&v#R4v+sj6?y%#)OA_oW5$RX_Z~VUShVNTzebtNjK9O}kuz89;P?SK|UgQS? zZWWSQ#KVyN$VNv{+ZI0B^GAt~T3>66B*~2v^K+6pcwik`yJ7UdtH+#va=&T4YAiQ( z%Ac2R(0gp~T(nu679Z9f0|s)r$(JyluhBU12IFh9GEyN-yQ{Q$!L?ylNZFUAbJzfe zoaSn~iGg>HI&5bc&Z@h2XU&K z&O{e|kfJWLSrQehQnd#|4|oxT{c~nXCwzP(=t(E4I?WH|1*Z~AB~*GE**P|WDotK3 z;5UcHy`H|ou-_l{Zve}VsY8>BJwZh)UgMiC`pP>k#p8ir5u~Oag;?J;xZfW>!?#+T zBdmL=fWzvb0k&BGV!!nmOY#g__No%2^qDXATg7hx`8iWWQ`T&>;q*m+tjc)e_mTH5 z4i6jn2W~#A!<>23hRXF{UEXBUwj1Hde~v&yZ44SyM9WUlX2a}!G}zl4{Iy{4AM6Wo z6^*~sC&FE4=@WcIh=S5eNp|Z@&(Wvr&)lcW?LJ+!d7oP2v^hj{^a&Zm)j*^pHSKHjN^2UKDEbbL!X+rZ8}gqcec)Pn5#i`jRKhWP4IX3Scy*< ze!uY)ZC@O~Y^8LlUCn84f@qh=J7SH z{+(#`dBZHWN2s~h?y|E6U&?qDH^-2hjn{8&#C)5N*LS-x9QW%5+joHMos?-BN(BXG zc?EV1h3pt)A*Y67pAd!h`>3Yvc5m-dyh_h@r}2Q(XdS7U$G5s;?ybk+-X$C2%5}HU zmCgTW!=X3E-1B#t=3Vpdm^OcxVcdlTI3Cy(o<>cwRaXMqhXN$w-Bew;BbuH3Rw!dG zat-E-w(3i_e=)FU;G)4qx6=4K3R`yK0iiVYrR*h; ze&VP1=*e_)+hkJwfu@~;dil7_)m(kb9ne$-nv$PeS7@@I(?=YIqClvA2^96!hnJ^z#j_ctjEuPHU~p-ZkKsFPu5ilb#Fy}9fK zw~KRwbCP;2JFcHjWuHkMQ>G0xv<2sct+Yub1ttF1q?$Yq&qN)z5>f+>Z zX}7XPFAW3(D4d|7WR4n$hQjXR$k?{(!RofLk)k_{+;fWRL&zA6YL(f`wR@#;q*1jNm@+fiBt9S7bJDB^U712YBktCrnGYAm01~iK zP}1#$bc&#?OfM!S?xfp&M33wmjVL}(r03w?i2g6s3#szO9IQ);d&=#1AJz?Xix}UA zItIb1o^2Ci%rx{5w)-rzWfooh#EP;{VGalO$-f9Z13fRD}05mi^BMjUR?SD z5}rSU(y!YSu$n;5R>GL%Q7d<5&hFRtxlke=A|FvD6iAt|xD(+GJs*+yVJ$~jhT%ULiKBOzK z`=GByEDO7r;G;?R-Rd8(tvD6-HvjcrTYLDV84i1e-rmgy#L*8)RiyScf1vrF+B;GQ zD&B@Q89`P)%%28lW_HfRc#sAZ7IW;|Z>M3Jwms}fc?ca6`FA@`BI0Ng#hOo}P;;{6 zyFj~M7;eW}6&sSbJr>dr=c;00xariAL z{ch_$a8*^+&zL^HS7WElml!W7bwc<8n{Vuj%*;c6TF0q%=TPd>1#gJQQ@i-mZpo&5 z|6K3y=5xF6Ki7Wu^KJRw!BzjKZL@H6Hl60!_Mp}_ZLYMBuC|+<&Lka&>+LJrty;U;`Qnai&-H{x`w1s) zzUu#wG-ZtYCBI8vwxBccKVt_z;KojGlCtBy|7|wFu_Z`a93` ziTZz2(P%2#3|MJ+#5P|EmsnInd)HfTQ{A_{=QafXU6D#bqT0*b=5v9l-yi)9Tvrqb z(&QVPMeK~#{Q7~}t%nb9oy9mfexQ_JnCxp#?#)AppSjL;3v%}TDzf%HioAXAMCQH^ zyFTvv3~K7OvY5MVFTbuac{29r;m-GmOb7 z3ZXDrF0pwTR^v=;=l&=6-??}6sefd5z){|^Z(jD7cA(U3Ji)KMfwyZ+>fyPFzlUJL zJ}Ir-vCJ~6Z6JXey;r&iN*6_0CFYlPAM_%^k-02=BVoq zFvsj)8bVMe`c60=30Ki|<~lj|D)BoMmW1yEYlww?LOvhr(T;=?p^>c6{l1WqY@O7@UY{;c7W$VUy-%6F zX7fuN>2jbVE4s-a@h+U1^r-%&{zAv21gdVy7o`N=H zu#F`qvMbG>vP>l_WRhoNsf3G8o<0r8|LTeqwa#M%>+*0t`qu##2{#dDy3}Z9A3 zvta*H4G3EtCEGep3ZdG#GZ)O+w!QJu0+WbD)woV_%hYP&eDNvnpX{aL3Pv-r=htGH zXfT%)r;@qmAkP7rf3i#oj~mUBah#hta5tYtO=uD(`R{S9CnRnB!WHV#j!_+q<~=;x zO=&yA7S(-k#S`UzbCu6_!fXjqEj9jf$-@IqB~t>JByBPC_6bg|kD4D8-3^HjC!93Z z4w4nWGlC-S@Dc&ZqN462D9lotA5oR!EN0>BjO7mSb}bXDO)h5131Ve&vPNhLN{z753O)j^c5q=s z17C*2h}j#=hwflc2y*?32Iogk(#V{I7lG$z9{h>4FGATQBEF^Hyx2(r5l(6O5vjOA zj)$vau%JP`De3WVqhG7&SHDf$T(;+Wci-&6EQ8IH<16vnPT|OVjt)Ud`7;^+72HXh zTZ|pFUd!b3w?>kI{HiP5W{7Sz`gt7I3Iqv+bi*>`0#Au~#65l!2T)VU=wfSQS^m(zZ#jt$R>e z2Y(kylxj3jz$Ns!ajW6IjHdfCuW<#Fp_3>0vK05}AINVL$ubBZ2;hSUZJovZWVXr7VqwLv!n-$C&5(!|!CF zEqV}G129mpqu;o2mC=F~8Vuo&+i$PY9Fs0_mFHMO9!GeP_JAV4T1&-&x-u7ew6zLS zq^E1Eie{TNdn;K+O9?z$O8q4JM=#i#~_?LBX#;)C8_)s(*1#93|C zU|#t4^NfV4e>ki(PrH3?^;)kod%28ZuR#y9jCq`5t%IypNAoEa4LMTCU~!;VY?ww9 z6Nl3ja73C9DBUSOsp3GQGrG>N@@lUaj&bgZ<^(*deLnbA{|0j2lecZ^$_clw{$p*W zb``T6cXVvUF1K$D<=nJ!CGGa__q)?d7SoKs?6?-r;=ji__yftNV7!5se}M0KKeTSp z5)%HEYBzvnm4mpz4G>EJP5?Shj(P7R2R{nz09vZXowkymSXinwPi#LjH+FP!;$S@0 zH!{Bh&Ar-y?FyhVlBwIje`W5StSlYbKDiXq)WxF{L*e<6=v!8xV3~6JUZD^;1a2C> zfEz8CAiv5aV&njyu#;2BQE9m%ZUkox}RJfkP_p|tAigU;OrD`9IbRjF_ip?RB2 zA?Xnjn2lX1drjh~*rh~$kxcACt1s+}eyc9_HdkOvLZ4Mt)P126b+iJE8}q;@^M?IXpS~0l>$*OoD!?_*WwUh_q7QalYrsq?<#t{#vaXZk zw`!9@j1MhkV)-IjoBwXj;%Ne*W|p>LEKK-zHowoEdOOb`mNTEjhR#u>BKP42o-KMe z#e|CoSzlbDNJ)zVpF5TSVmZ#bOe+@9JOxQ7*AV*L3WKg~x>B#yDKc8XB-;EUnMElC zdTmn_7XShBA}$(TB${l~KPR5*R{%+p>*8eyG8n-W79_hxA0WBI&i!iFdH6#Ruh}ui zHhZ)lBv~9Ip$A0rVh`?49bhR*<7Ky!#}bQ)ge9fvR^Lzx+1Xe+MFmNcsYcGFaRa$BFu(w z5sh$ddASyDu24^?t_=4F%{n(l);Df6ziN#8xU&rg6>C-EFc_nlM>>x8hFNAJ%M?5v zc^xwbvNi4bT*aZ@-M@$;sRb;%{%rR19Rynv@6!n1#=E&F_KK|HCuy@op(RYL0k2Su zUEZ}F-m*6WCOY7j)(dKo5Elu%4<$9>VVIF=Ye1wy$_sgzx?$l))|5ojlGO)+o`{d< zcqHcNfN6oCK@nl>{(XUL&7zpPf9B^%Fz|SJ3^>@jiR@L$CsTt%A~hq~iu7CDk)eSb^j8 zxeeVs&iokpJyI;wqnO`br&bPp;jl!d_W~N^xj++>gglt!@P%`H(VUGtmDlOpOT!50 z{sY6meYmt;AM{jHzNlmlwXs`9OBNF5&gV#JMy$tpm_mf?y>QIfTf{>o~?KlH_V2 zQz6i5btiBsCIJEvxO_ypu+5zAU$l4Vfei-&qaZ#4X@@wK(88az7^V_wzBDhb$b{ejrB7=&_=SP zK7j0JPtq(R-3@2KLbeA2NV2OkSBs&kR*I|xkQ@!HslY2VrcyLSRukY~_M8MdB3%KS zq)~xJa>XX&8Hh}fhDhB8q(kHfqYNyS(65M(so@wDf_>q>h~Ix6 zv%&j$D`TrbYZ}NCGXtIuDbHxlCs6yEejtGb-Gh^=jp+N`?9jP3Pm7Rhv#zKJGo(FY=0R)cLpfNd^ zNcEcfqF?P%-01-!^MB3`MUpF!NE#yRH*%V80ZoE2t|2q%;N()}9>X0~O=0e1#h}RM z=YVc&x3Qny%XGu<+mjhnG+32nUALiGVg0Yx-UC3AqI?^l>PnM$Hr-wVc5g3n-0bBb z@s6B1GKj*>&h+lg?IfmWc5g9oiV6l4jG(9|pkPAGVB$c(k>t3nH*vQ}qh#$W`eYV^R)#vmm z!_JsI?!}UQA%;kOV|5nt&n>~|MZ!HKoOafh&=i5RZD0sdg|x%L#_P>ESXIq^k81bK zfv=qZWMq@*5&auv2Ft#v&h>Mgc4oQat7>Ozec16ZB#n>2*kX$e+Wb^I!wMvD9V`z( zn%F#;(Qr>lF#Ab%*an~jp_1+x&Vp*P!#jGc>^CZ#Iap=&#fhU37>Jm_qY^&LgtFcE zVD1NBT>>iEO#2yu40NYHPJ(6=HZ&m~NEi#~#@O2=F2gN$^!yMCc3@#|V&&o|Ba2rq zJxh4EdLq7H=+HI0XBWgRil@@?9{B~bJ>Y7D0kUwxaBSg{+h{m%%MR~WTC3FJHBaoI z7p22jSUMI@_l&0F8LE$i)w|b&C+&hyhxgrbcU7AOf2 zed{;FZ-kj_3=P_Q5VDbmY+%71f4bw2=|zh)+mXGP=EcRmOSm_*4r(zQy!6^@KXC0f zSn>-N9UjH{_R`HI-rw(eYmYTFwWK!|Td@QJnr}Jl%m+@n{N$6@ty$)&*Z^(rV);u1 z{KeXWr$8dxhFN6^?AOKjw)4)?3twPUTd;PKy?j5M2N_`)`e%Luli~PvQMPu#*tBLY8r@49DvHqB zR0ZD1dk1DWuC?eXIygeKgT=#anRhE6-c;s)`(fRB1Fnt66VbI%_Q@0I)DXLd-|8*r z?r*Zp?d;vS^&nk^ z+u>l%N_OD#;}1|Chy8;_HO0Y=v%4qp%3(9ho)()U4|oXV5Iki;^`(79Gi44w83`=+ zJYaov0Iv39&FKy^DL%h%pS5r4J(%H3yFpv%kN-v`Y}-5{VE6sEF(%yJVp4I)X>i-a z2uLP^dzwYaWWrcwfn*{yN0y02uOLceH_cym3=}QrTCtm*E)8YvnZMTfTel48L&B!D z=>2HSZF>^nyU?b#ZJ|x?>1ScwyrpRe#?Sxc{x78J+nWVyx`trr_oJl8+7f11_Ps5S6U}x zcSUjbkm04+B*`k$_N(V0FlTQ#QriY7wy{T82_^RQte9N!{G->bIeHB{Ud8muaj|sU ztG6u`@so8-RoG${xmQQG6Ul9l>U#$dqT-sPAC*xO^QSCacnW6D_Ncrp@;wKiYRCOb>pbd-cuK zIJWxCa{0{F-Cx~J2;=;}-RL{(v!cC3C~kMycmbcr<|j5uJ{J26MmaA{`&clMkBX!#m&xd;yw%z%&dmFmnJ!yY|yz7pjE6`zZr-h;+eym`}78mTV|% zX9n0eeO6|=jZw`GF{_}M4%iJ1VdsZ@y}iYDh)pcFM2igxKo4-cU?TDENHrhbFX*gO+;MklfPWVPV3i2O(Q)GwgD9pFxlJ(UINZA>vuE z`wfic+WB;CCc$7mTr>09jWt)M(Sy;uqxd&wJ;n50sHbi8%p)D`-DP|mZ``p1x=8fN z&$GX?dmwf=#fkxNOMbn451irTt+=*178X^dl1E}_U7n+p3k zlVUOO)FNY>d4}?51`Yi$G}JxwXy+tYAk}40T&D0{W3A+=pE(UHa@HL8j^k{B$TqVm zz(VhRj(OS8I9Rc~Vndv6Jl-%eHQHxfN`spxZ~CU*{Uo#1gZ+ z{|ljCn2j2@LDSquo>@J-6-uZEj0-*hj1Z)&xlTv1tK%}WQ}sNqa0FhS7{+n4yKTW^ z?)XASicHLbFve!9lRW1iqt>lhx**lHc)2h4*P zV5^p`TGxfG`e`XwOfOi^yZUt{Zl`WM{glMo-X$aH*nfU!C}YF|rg^a}*F9dYoZL^tgG6 zwY{;K`wg+3ZjMGbc-*@NK5rWpiTJUn>B%-r4pqGWA6@A^=fCvYk_7dbRlXOW`G3}L zPjvR%bDpMmviVsT&DH-Wz4JBGFX!3bv2*ZAk7TR{C2q#Po4JliE!aJFX3_T7hQH#2 z;UG=g`8u3*(BkRbzzW(Co2*2v!p8<^M(3m8!?vz(ik&jZVk1LCBSUv?izhJaqVm*J zWmL%piTF8^FI>p6^gE78Bo+v{>)Yoj ziG8%UOQpmiXC5Mex1xC6vB$2H;;iuOF}C*kwiwOmsOxysKJX?RJ9i{SRYK;F5=)!L zlWMlknU0%6juif`Sb#m2ErLo=1FB;JlNJJZBCLF4T5jD)`*>r&OE`nigx_@uWvsk> zjbTbN-;XcGro$^lI>ve1!fww$p$ojCkZiZzK@ThrJeHlBLbY(zC|oH@2gxD`_9Wq*f}C=bsX zBe$FH23&50``t!@u&tf;$PRdjmad+;#poB{`PyL56dM?>#X2r>3cOrTk%#l29D0U% zKWRp^!T&ax<%CniiF_1re8ji2O6Y|uNT?303^)FbmWI_L;#RH5lO&C3IYhvk9 zdCRvSjK>F-En7Flx17?|HS-@Gjnd{AI8MU6Gd3%Xg?+%PFGXZ|9#2C^u+`gX7jRM=${XxgXlpZ4f~bM8o@S5>ED!SSr}J&v+N`VIRHz7xp%t7q0%V8GHX7 z_Gi1Pc525F!%KAO;!AAoncYX-x@`Wp@ivHx4(AAswp*osLvfZH&8BW5ell}|ouNi! zW|4bBT{bq}PF;;!P^VWe#$-m@_>=zzhWQ!DkCljAIu(?*0Ny|>Py<^X@ywp58wX}x zg4jD5du0#_Lbo`IxXQL(C}JJ{MD~%iWFphItPiHwVH;AOOmh*FTo0~3`S8O}Sv9D+ zjEU(rC#F?z_sk95y()d;n(pD1D~Ge|Pd;(OqE+d1;*ex2y^39~QarYF;bBP%m+slV zSf`Yyd2~TnI-Ti{a=ga+m0e4gbgf*U$>3Wpy^VM*X1-k%Vs!U`XD)@F8414urTZwD z%}yL@Iv7G4E_a}F4litrMTivEZ>-84Yi54dxAdHCritAfS8m+#6!h{wyy+Qx5LU9Z zZ_T!IGMQyjtO^NH!%tYu`pbZ<31b4#3)x2NB#0~0R~+c!WO&Y9Cjqr~xc^WnfE3R+ z3>|pWmJyJ^wk=OxFR^dKVTWzNChrgJ`=d>y=Pj8yWqA0R!^3jXp^NtJ-ybIBFnTBQ zh(I(;6qiFq)=(&OLn^5@F;Q~$S5;P?RC1iG& zPKY6rw&-wLZm?=A1h>;fbhu-C_{%Pna^}lc$zYrD`+%`YW*!pWh>R(@TQW=GIQwD;J&nIj> z47)t^W6ePq+yxkBI1HTz)85AdAJZavM(eE1+A5If`mGy6YZ6M#p==xU8NC1>Me5(O zX@sr9hd`cEcJStt^rF7!En3F*_ASyU-MnnkW%~xY*LM%xP5h&W4eeaF<~VtxJZ{ap zokOgL3U`f&Mcv(t#1)Ig2qjr$Ucte;`i6KPU$gYte5miPYnHCz%kR7stFOLmv>Z0{ ztmC&Ge)zVX>(EiA3l0&fES-dXpNaEpg*Ve;p1bo zGLV$M`O9@|*?$ZBuwu{xbQF&j8!*d61^`D68m_wDv=wRSXb$7DNS8g*mr_2MiVf6B z38)Xmkm=oJy34X!d+Y?gtY`oW0F!d>Mlpx0fa)HH2(yc!?N+dk(!FOps&Z2&MAzAB zC^Hb`_6`0|$~)_9Giyw#hwN86NR76^t>pP#xp+IIX8Ygnb_$6(ndreN{d*Fk^#Fa@ z#ciC*@NI>IbQ>II4w^d^&GC}|lD21W%0TJvo!Ml5&`%l>YzMsOLNfC-s#zjvng7H zy_+t>!P1o>u6<=T;80znEAlN}u#Re-gWJshi=OcIkYu`dllMp)a z2CQ#MXEV6=wWVYo;g+rVBH;a5v>ZS(j$GMe<%V$6CF~ z=m-Cd{VZZ^Xy)-4HbeXK2k~jFzLq(A0pdoY;}7B(`#l@M+1Sk2e~-%12gjp0UO?{- z{mR4=?S}{U5XAPw>(vW`&>%ts)*m-4$t!Es=FM9>XHBPKnn|Je z`EB<9_bv51x@YIpW=z=xyN@a-2ltZ_yD!ySHBV>oY`^H`pwJbvdw}GE%pGv z#1{*`uuF9hJhpgeUgE*8gu*ic-lc>^5wV=?jH@4R#Q=CwmoEzKgB zM|T7BT-wz;a7`?`RLP;Wo0ngAUH3q5*V2@XcU#&cUF(2}hv|k!lZ{ZsLw{e|LukgT z5x#{!)aF7@T-#@JoAw>px8}s{hd?VoWc!J0Ab{2lf%ex8?H^i)2|x#S;Of%q3s2v$ zARb?^;q(hvm+p^gDu+&muUFC^^)PP?bWXs%9dfNcFvZeS)tdp67p z5o(=>qaz(34e{2nkRNaNQe@sfim57YR?theIE z7US&9!#h}H2Ym`-A406mKf@FTaSqo*Dw}^eiC^3S6-{x*-XTNfC_wRgTzz0S{xdNK z)<0}wX>E*x4e@}s0c3?D48qhLo=Fp^%{_NvZ740%!Ow1dbITlA9j-ijpxelk);A5J z4(7(7G4tqn*2=|qn;t=Xu~^gCA2fgMJQ+4|c(dLr`@HbU7I_PY0c)~`LX0QeTwo>H z^Dz0@!z0Jrri6WhX6*+pVRr=l99%T>af81Z6m2OxaQw*3zS&l>gOsk<@g{#g;{bkk zn|ePnN6?yav>SY?3$r?mo3W|}n2hr%{f}+OO&w`4ET6SajhG*EIDqYbXXV7c<3|p3 zv~D-0AHKoTJ;KqPZ#V%x;s~s3IwrCkGtiEep;NH`uR zUb9K}Q8*w$?uKOoZ{K#TM=B})sQV9P^>lm(Av8PUBZvgkYFjcLSBkwQo=$F4${at8 zKtX)O!c{!Aue%Fhp6p4_>`V8=nAg?acj|TkBim19aiflp>vTNL2hEWqsR!M0rm%AH zVyA?sSFb%`VHe^~vGZ5&;uzyy3r|>kwy%ABa+E0pF)7#N5MBUyXIqgF7Zoxl)Ty&CXgW#XQA@%ZN=yw3?SAtO zRQq+%bVrPV8EaZ+cp#$9`ne-1Dl^=Pz}a!bL#CU;He*B&VWG1bQ_L}8E9Rl9xIDYr zF1V-m&D@QsE3^Zx*?s)wGiAff>P87M2Z;0?sop;rNNe2m@9OQ_SKAPR%a7k*H=OL7 zsA7XNg9DU0fG)>>OW8%FO`87}BP!aaS!5xiO>El3Ti)r?91{O`E-fMp*$$VMk(KNY zmsXK}_D`3NN7j0KTsjdM@IL3#nMeBUAxpY73yvd~(pw7oIpO_%= z4}8a^S!6xFQ)kOPSXsBYG)D}?T`nym>si{RW#mkDsY|QK8upM&$0L_`*mc+3KM@)6 z-r&-i$a3#-m+p?l`9hcOiLB>qT)IE9oVQ$h0qWd^c%2$hm1%h1OR)C|+a1I=Me@*9 z2N7FR!#*QavJ;w%5B;SE8_zV8>R7Lb8vP3@WGR7)#l*NDL<%HaPGfdGzjH) z5FaXO;3k7mIBNhGN|6FC=(v1v%iyj?ZSeeBxr8$vm0ZdH?ai}z=m;ba9WfIB?NQk+ zBOA7iP=){U#{VB0zX<|(10K5tVLF@f#;gB_n@^%s8|dDWdHj*6-P!EJBauy;HjHf9 zxEUa2XJ<|Nji6Mk9yw^6k9uMI=Z@DF*X`NP9;G|ELEC~p&$=GI05ssE0W{vD;B5iY z6*Ma_bWlgC9%*6da%k`%8k{#x9>jUy)X0Hi3OLh5ISugy>NL>}kqe6c;FLeQDj2NP zg63eoR&SMajlojBRvird=3uT`7;M&R<%v=gwTTd$cvJvq$vXT$W**bTV>=0P#5}%& z>dj_7IC9;(F}zgHQsPLyh7)0j%qQW_0eu>>_T*n%ISQN)3j>fo){?4iekKwz6$(J9`E@5+>r& z2$QE3GCSB#c04?U?Idn0=jyM?`(y@kD%?PqUex3b&V?d@oHO_BcDp ze#m~re$0Nte#(Bve$IZue#w5te$9Tve#?Hxe$W2E{>c8s{>=Wu{>q+Ue?#2bKiEIn zzu3Rof7lFr(jy`;Yr#Bh+=-=dQ7?vFwiEblUdl^*U0%lP_IkWtug~lE7I*{RLT{0` z*js||r$f9$y~DggZ^&Eb4SUPI72Zm3mABeE+*{)v;jQ(u9u`DlQ|1lcMsJh18B6ZA zdfUA1-ZQ)-y`#LNy<@O}^l{z}Z>M*>cY=4KcanFqcZzqacba#)cZPSScUEMXceZzq zx63=%JI_1cyTH59yU4rPyTrTHd!~1p_bl((-sRpE-fl1Fje2>n;Q8K|H|`a^lDEg3 z@XB7rt9muB?p^6MyufREEpO7B^7eYu-c{anyytq)^PcZr?d|hk;Jwhh#(Ry!Ux`d++x?;C;~hkoRHlBi={7 zd%SzSk40V{8TLNzeZu>s_bKl_?|^r|_kj0l?=#+Ky$8L|d7t;b;C<2ilJ{lrE8aui zSG|Y5uX$hhzTth-`pWj-gmw4d5?PE_a5_p;63gg^nU35$osMP6Yr| zzwmzP{mT2b_Z#oG-tWBMdw=l$=>5t2v-cP8uig{h-@Ly^UJ-ej_Yd!%-oLzmd;jrf zyeD~tGftan2rjW|L`R}L%40mv6Nvjv@igz^SX{_^crT#ye!hSY@P&L4U(A>ArTh?n zC_ju3@*%#A4`WC06?`RM#aHvg`5Jx%U(2(6gsU8C_;&sbek4DN zAI*>9$MWO&4!)Bg&rjed@{{<<{1kpFKaHQx&){eBv-sKk9KMU6%g^KI^9%Te{33oa zzl2}PpUE%d&*IPKm-8$5Zl2?#JkJZ<=VN@F7kP>A;S;>fE4<2Uyw0!W4Ic0&Z}CY! z#rN`QeieTXe=dI>e?GsO@8d7vFXY$o7x5SKm+))(b^N9LW&Gv*75sYsO8zQ-1HX~K zn!kp>mcNd_p1*-@)(X@8s{|@8<8} z@8x&#_wl>=`}qg>2lA%ql4Xl(l%LzF}UamFcxAa{w3=*G&kUToFhFBXUau}~}$i^USL zR2(7>6^DsIF(j6WVX<7S5G%zhv05B1)`%m-T9FkaVx3qoHi(U4lh`b_h^=Cq*e;$S zjuc0Uqs1}eSaF=#A$E%6#R=j>agsP$oFYyYr-{?W8RATFmN;9SBX)^%#d+dCslTja#3$cuvT#h4fuMNtxa#Dpk|il~a3sEaE_Ljx3&l0!MdHQcCE{9fop`BunRvN)g}7e4QoKss zAZ`?|7OxSn6|WPo7jFIY+!nb<+%DcO-XZP~cZzq4 zcZqk4_lWmKUM=nt?-O^6_lpmR4~h?o4~vh8kBWQ5z2aly+9oC&`oLDe_c#nmk>eAe=L6zd6WF9{F(f@{Du6b{FVH*{Ehsr{GI&0{Db_X{FD5% z{EPgnd_w+B{$2h<{!{)-{#*V>&d4WKL^0(l9{H{kSc|W)QDW zs~**h{pkAD0yUr(szqwCTB4S!L)4+_Fg2)#uxs0}TCP^8m1>n*tqxaf)De*vsMV7(I!EnN=c@D6`RW37p}I(2tS(WPs%NUp)U(vH)#d66wOi%XsLHE?^3|9c zk36P|s-*U)2~}1VRaG@rkMyW3RYL`;sak4MO{u+VT3w}{qn@jtr=G8_R{PWo)C<)$ z>P70s>Lu!0b)9;tdYO8;dWE`Py;8kO-Jot%uU4;7uT`&8uUBtSH>sP|8`YcCE$Yqc zE$Xdmzj~XxRo$j;S8rGEPV4{N^?vmM^+EL^^tQneO!G)eNuf&-KP$y`_%*L)9N$ov+6SsUN7v)j{<`^&|CT^%M0|^)vNz^$Yb&^(*yj^&9nD z^*i-@^#}Dw^(Xab^%wP5^@RGH`n&pv`ltGr`nURzno&>c2m)$7#MKKeu|I*-`+4>y4OP{OH)9338^o9B&eX+hoU#g#}FVoM`&(@dgE8xz^ z=~1261?}rGJ+6zor1$6vUDg%s%2(5MeWh;bKsR+uPwFYXS5ND!^mFuc_4D-e_0{lX zyg-8)3tMm=}M*V928vR=RI{kY627QyhS-(-g zN#CO1tly&Fiv3XDrf=1^>D%?&^*i((`cC~${Vx4({T}^ZeV2ZpzFWUve?Wgwe@K5= ze?)&&-=puO=|6Kn<|5E=- z|62b>|5pD_|6cz=|55)*|5^V<|5ZPs|AtR){6qg!|4aW{|3}a0C!>)li+WH4MHB(( zQN+APW6^jt5lu!@(R8#6VPxIWo@j5hFWMhnfUPAKMi)gFN0&sGMh}S|8a)io#UXr? zY&g0+x+1zVx+=OldU$jVwiwFi8o9|_HJd9pWBFV&8`N`oKc26Rjro4IUTaDi>V7je zhAPe6Xc-eY*UJ0Ng#F1D8l}1#&y{m~r}5Lsjr(!?lf{h_E?KMlRZ;YFOY7_nja5qdMr{fWP_^DVi(yu1!p;Kqes;9BH(9SWnz?e;uQvTgBFyY9HDx1L$Td_WH$C2}CL8`3&^W)5Yoeu% zT7CleE$3R*d{G5{HK;X`_6I}YH<}5T87npXm`m5HVU?g#oA70TbJeIhX6alFP)#z@!0_5QS6#6b9p~k z$Off?pUu}=ZIy^tzJe;8kmlOoq+b{$$ATO-n~rBWP)0*o&<6E<$}Fh1fWl0i*fs@9Oiy@<9ZNf z4fN7}I}j}8&71W!Yap0rww?oB*&@@?flW)(s)RY(5^d6cASV5wnWO`*#dG{1HIA+e zvXxu|v!Wy`t)P_0MA~Evlz#@ z@oKj0k2RAvnYUckW{fu@Wz&0FL9;YAE$Ce|Satyw6=oWKFk}V4CQB8FA%!WHFO}n! z97qSv@*65wplbz&s3|Zrn#z(ISejqYVm3`ok^UY(-%MguCmZ0d^lGUvW8McO-U=M6PMXmTGlmAjj3En`>AJE7 zsv~A?Ieem21&6ZfxM3v4T&WT8(OfldUd}u=VUy;eQJZen-FX^e85DC3U-^6M;0#g7 z`+CC(HtogICh^_yT&)$y_~jqFT<3Heos zM4~;2MX<$+#&aq(bQmm1+IZMCt$Mr5sBIcLz?APdQxG}zIx#iq6-{N?W5RJsVyE`S zz)QkJF_y<`(NH$Vy#h4I5;NRzZTX1QR^pYNDU((=o$f^W{=q1f*u!t`QiQKpTeCD%;;$8B?)Qf__wNkf@2Z zFsmE5ua8DR^^_o$QeHM2t^7nB9qvzOM@!{$9Nh{wPA!kxB&gdasLM#h#cA{IAUd$P zY5+meXvT~-WQfe9TJ@-3o%G9~CV?l{c zvxrEdGfzDfw~I<4P)iUuWZ$twjBwZwBv$z ztX9FIE$Pajg1T}Hia?cD{GuMm{H;TKwLD@!M+kf3o1d7hiwBYX^q;H0t+Dp zrrx^hDs*7I^Ghw>s zl)zM(lQ*3a^IPzX6F-JYhWToei9!ui651RUH!zBL6q3AVw)c8krxZ z1NBvx;RU&Jy_hpCNp<8~b!$@PDtMsfIM$?c1(-Reb`@$=qm;LAGN+_fw~{457bc91 z32d!$4Wi9*X6n;aM;?5>BS%v((~&oQp~k?H%D$ij4_Z}0cB>|}u{7!P(bo9f9;SQ5 z3$@Trj87GPzYL~TYJiPFPExO=oF!OnHDMKZg`6=Q=2jJVMtnnaIz0|+x8>9X@I~;b z@fH*?K9<`H!Ydh51j;QEyq=p9`PL|uRU87y_WU&LRF1)gBDuFDnhoEN7II)2m}z!M z>V>hG85^<^<0gff>X*Ar(x}5mJla!6^-J2Zv~O?AbFD(DW|Kxow6`?eqFfsDAsv7l zxan7|L8nhxtH7u)x(HRX)|l3$E2Cm8K>Q0{K3j}gY6jV4+9}sV5EhkDHP&iEXz*rp zIzC#e*76io5kQx0jmN6~6uKCEModBaLtvHZES?i5_U~*En5986K{{3eybG1lF46@? zVN?}Uqoqc(2p9#5#RM=LqwZjK<(%dN4&@3-hQ^zh{ z7jUyWshD`t1enwWraRFCz#%A8q_Wc7gcU(%nxS1?D1rW~1wqEXHB|#28c!248n0vS z4>^aSZNgUbr((t;f>{lH-G@yBs)Z(i8Ix}{rmgut4s8+0fHBH#nx-a9E>y=eNtPw( zDwmGi6I5c0@igEhDO1I!vy*djMOF1%;7)OScbLTi?6n3+I%Xs@y{9$rjV{hd{Q~q3 zbiIN&n95BjM{`YFKAjzd7PlbGKyLxVAR*$%e>|LmjH8T?Q62WznNx&(m2@tFis6K5l9BgKR(&rwayl!4ClhI0_$XtV*$3#Z^)t5`#k zfC}m_ffNIi08ccwyic>oA#%Ym86QyZt+avNZ~0>u^Vn~|)FORA6kt@P=(XDC<7Lb* zfJ=C!85A%WkaU|58we>^D3GZJFtRq03^SOX4l%L}Em|tT2r7*G8VNd?X+rm?Se@1o z@Z!#bxkNmtE)h;|XI)3k-JNBIprQK^Pyr<$07t!6fLhz^au8SroW4@J%5UcZnNU9M z&f|>;q)A#U*eV4UrG*fw$AW5Y3hEzVPy_NL9Xw$moh0UTH7J#{u)!x%VP>KV7T?Z+ zSTTt1oH1acVFoiEJ!G&`lXN-{83ROMaclHZG8!7VF~gy$C}rDQ^!Mh*0UFy}Ef3PF z0jmZ2LX}`@I&dmujLr_FQ$iXP{G_1)SfErpVKTr7kq*%=J1anqt-S3$#BRmw8+Dy+D0eA6d@dW#-9DS2Cu!MUJn{jWUjC2i1#RIZJPvuBz z()f_pu2#xI8DT%zoIBUsp}61@BR=Mrk#f>Eub$O@=M}gE>{T zu!tKygMneP#cTpy3psAa$SZE}iW|M+CaV~jp0!Cccs6O;Z`B3rm`tdFgwTjRMdm$_ z0s>I7MluH7b8^&3SQzF!b^IJ~#Wu99$~#+))WG>=rf<+rgDAOrb1ef`*s7;Uw>Hdy zo+t9<)@Y1uaAQBU?`t1z$05A0E1UByS2p)qt`J%;xC(^3)1Ro}b4%z}PJ?%K%#ECq z?~07kZeSYp)vUyzp6zQ(jL!LDcv`|7ttptaJx_g|Ep}!m9mdh|wsT8N1Fc5W`KDcS zilB$vd=pNATC-WJq}qA9LAsqYZ)Y2jMP*ER_c&Oz6?d(>mnosEGxlneH`lw{nF8FQ z-=3#;>$pqryi&ST-@Gz&HwOf@FUK7_u5G`OYbRAhSx2|H5_2!tR9A#MC7^gTDp}}{ z`H7Bpc69esOX=PVW?S0X?Hvts#n66cs}g;6Wa&n(GpR+LeNXvTXU}y|Xh(}&>AV(o zQffzw+SG~(PQ1negG6)-=a#s(<3s|pgFsBGlmrPwQyMqMrUFy}JveTE=rkBQm1H-- zZDfL;j^!OR=?yE}l& z>?pYD#GDckUbUHU_zvABJ&PyI%}!F547ds<__$w5hKii2IEx-oHW{J{l!+OpPU;Pe zVB+ddzNAUB4CAM^mrejmo`ih@A6Y;+5$URczH0SMXNF!S z;R({UXTh^o7ek{wTmYsIg+rXKXyQ&J(mHhh6Ajp27jcK{`#T07+}ye)RI- zLN*Psq0r7i>8x^$dyM?i7@PcrM9L4k+g<~38RL-_qloJOFj@~I%|z$wRX?|ALBHKc(NmnK0T2x74~K3xyn z++~943K)698D{3*CA@5v;HDIr_HZiOE>m?Hl4jigP)%8f_?-*!?CK5xv1pOkwVw@mclLoaL`ZLu`5eX1Q=MW@feu4tkK-c01Ag4BfV5u+D zSptwWm{UON=s;(U4vloqy}%Pd`JFY5B{An_WKhhhV&wST67q!1DKWMQY4_y2bc%17 zqvjHeH>aqnz3t4bGq-)MX<|EPn%BOVn$|vLnl+oFCbe^>IpJktmtvV0ULP<#=onBi zV#q*Cs!*%K$0-Vs2-=OA#fi*VS^UV&pdK@O!s%qr_s=N`e7`_!ckaA{{XX|fKm>k6 z(v=pr0%l9(V*o8mmm#ahExdy0l!7e}17eb42Jjx7_?;QUg?ii>%aRN$id*f31|(iDjgQ0W z3B4?dprU$~f`jk^s!d&Xr6hppyX zE^5k%2aB*8F>e(ZxaBSWO0=6aw?XV20Lma`GS1cD+e)M!+FKafwMigvdvXqiHt|8W z*pRS&2oM>UDA^k!2%|RM0~tu(Rg0b))2DZCk+Cemiwrgm4gy|8tfdQTcg_G@FqwdP zN#tu40^Z9L#@OzeO+o^LB*@QMY{5N&c|ocY<|u+LfrgZVJozZ6Bps>(qSA+OLz~6` zEV>kc+Nddm|Jx>l(%!f!G02@QYfjZ50wFSu)>nv`v|)rkFuQV~$3UTIh=58z9v#mG z&^Ib|ZHCVelH-u;m^e^cbJd>COx9eEpdp0v)as}bnMNXFGSX*w5!khD+Hkc>V+dHgm=9s#CmP!JLL zPZ#8#0n$lR01cDl$%k(j69$I7Y(NQVq|FhOP=TYyFJx>Me$>)cG*`;*JUj%@!*gX7 zKsSfvCILBxAXt<``KkfKr|73Z1``lO2`3t9#!3Tf68;DGCrLQW2SgT$6z$*Bhigv>QD)ntxhkSG#x!no^_qyAMTj2m>) zNrVx=rwD3Zs~Gl;hVTe2ey$VhHwX%ccluYt#$@QsfNkXYfsn=kSy~5!qtw-omx=Z zln_TW5v2BRHWq{Yzh#2)$|jl_r=Z0En8!Hc6gqDVthb2BU(6L)3^a?n=fwNbYa~s~ zOg0C0mCvPJ#^U}#++~AOPLDSLqUL>p-)fW&baBiiDA2U4oyrDNn1czEH-aXxX$T-R ztlN~^G%=M19zr)GP}wC-slBENLTLGz$f#k{WS!zRna(1T5w<0iqLxsUW=Q`jlMl(? z#CsaSWa!FxMzKshQH1SfB4PZ7E@EajTce2iMi8&uLorNZ0xl%53^D`hZB2@vW@V>D zSoC-if!#Qo3J4aZq;0G#r|L;KP{%Q`NrJ-k4}pQgEJgs61#`^81rk~+0%#COVlj;j zr5Yd?Iq){CpvNqqCj?ym;Uue!{5uhJy)27&x5E79- z0=xp_ULi535lRo060zU(OPvNc0Rp2iprM-@ku7kL!qzRO!?-9KK$C#Si9;t?n53Qr!!DvQV2kx7;#KCD37sR-Gl=pS`}XLN|X*%%YZ;YzX^X?Xg`vl}NKX}P)vS(&Qz>m0QdT9zG! zkR(SqMhRFE1({{-5(h*WBAm-7WUrJrRViZHB$|kvGYN{g1}uTFU+O{(19{mO%-Xu0 zq>ysV)4mHl>=?{S4q~4CH@GZF!tG;3pILhVo;wxlsV0p@3e)gAB}~TlJqJOhed>1~ zlD0t}N^975#2=ZnHz?Cr>h!MB|knTE|NAnT~J!1|Jm?qkCIfJatKG$lGZ z5rODnWz{JC(z1rM(_!40_RY=OS0?*{)B~IAc09+hC?oR)CTSh(O~?QtwhW~qV{@3d zus=Y5zEf&KXC{FH<+@)^x?gaUCKjm_VK)R(0D;g#Xt@b7klGlqnK3_2A{}C7JZoZz zG}If2x8j1?ph=>gEEGGk1AFHc6Phr;DwToz&aWE48$Q1(m7!`+=UwfCx;l#JhMiU1 z13IhFjXJBi8%w+!LetIBi2W2)W}_F=8V4+0#%zqy!C*`MCPkANEVMnv5~kE*A&9^M zgEWH!<0^;MA9*NA2;2xWMsjB6tn1$YTYIyBd+=)Zc7ND8Qg-0-gvI0=m=+F2I>k^(mcry+{~AQ6H(NUMhH%B;|$7!MchMC1X`mDmZv5ylglvU!9r z(OHp4kXaA-p#e3BnyiioWz`nhY>0FieZ*Dia#bi!h}1%|tAIln@HnE<+(aW6Gd6Y> zx=Y`j&R z6Y!l zJ+K(bO9f;XBPJ(u7;_D@kI+RmLC{DJp}LjPLXJZB3ZydGMROF)>>k?Rj&4T$YI{|H zU1(>j)v9(~oyi!U7^}dbfWRzIK=B%fc>qF3kXIFeP!UnHz?4nI9O8l@o_w!Xk$_aj zbKrd7FJn1cKTrxP5E7LjG2>mKod6Ohb=H)irwTbBH8BLCAQ%Of0K(~kkxOv1W)4~#jR=mV5|kMPlOYhQ z=9ak`s&*(189yN>p>!JRYzs4V6pANgjG8QATIG}t#|ak@Rp<=GH)c23pN+tYkTb6o z#pq5?Zr}qz0lzhZ&&s$TY~5WIZR`f3p}6}j>#Gd zv7ih>so<<2B`<(11I7#|mE}@U!zy)(!iMHnsgqt(Eur7FVH z>Atlo3(^!etPc|#U1H;U=z5^;!e;lo)%|Ygh1xhEopB&j&?EhVf@%j1FGhy7@eifj zDPyUN0suQ*K(wHb_#=pJ1TwAMr^R|L4)1pEl0g9|7{VS^L`Y8Me2 z%P~9INLi1V8)sE4k!mB!?NAFnVkC3{yw*V900$;uS2hCQEeXYAKf_ z2U@7Fz~*fFDH8*hMH1R!)xx#V?BFyo$SrP%DAc?ktzh`Ez83-1co=w76?cMlh-hzE z2(d5`=xVMp1-&-~nFcO2f!87f2tgsxgyC1D>4;@7(8)m~0qjEP)vbU-qTz2`6V`_J z!%C=8fGP?50~<1Ea)VBR-{b9L(88eZW3B;gBBL{If3mciF33+~`eNml@3$918tF!n zcL>qX7y!yHE+r!fh*T9Za|(VZkU3WV(hr%#Ky?uyRGW&sB5VQ|1!E~Su(*XR#<4MJ z7UU|sEH~M?Xfo58G4BlZZxq@H=`>-A%4u;fnSp7-yo`E;VZf3{@?&GmhZdar)AWNW zuEBn2nj{V23osYTR3SMDlM8fX@KiB{L5kA>vyT=8(MTW~kaRP|QdbKJV^&g#4wkEv zYqLfUOZu_yk`$N&7F_})qfs@hXYFb50=G_$7-!$CX=auw8M`87=Qj{PS_I-IKDa{J zg;-EBTPf}J3vT&O1+AjH!^s))2%2qGcPS;^Jt+xR<#INFGU#>u#Dr5C@5SVF!j+Yw zUAWs&P^5bxSdV#tGPMF*8E!;GJ=)5!a$D7C0hlbYSR|&5&Zd2OU#!V>zcg+LfuqG5 zwCFqvu7DK^uw_l673BCBIFj+*LI1_HjAF@L38vO`4B)m2c=dtbSdD@%1Pt8t5#CNM z!Nfps*d+Be#ItpYn4w9y3<*T6t0(DB_A;%phjh1|6s&}W=K(YYG}m@qjD9IhKA?CS z#E4VOtw7_)v7QKTkfcrm$=5(0D59rgagS>XaF1%1B8^R=n7hi?C?OyO5#Lp)hdrIk z&1pe@330Sg9%0y+CFx{hbSiGdP?L4h~YWC(5jC}l{8ARO5*BQke% z8jAfWEgryVfqMc*rrw3NDhW-2+$`155tw!Yoi+|Y1@ovqL$K^%cDf+AX(AMz0HCn= z7pns(h%W%p=8r?d06nrQvg4QDMqJR+w-7Q;bjg19S7;Fg2GFmrw=REEkp%@d^x;TiQ)=5nxii_LR#(4TDbRpkG>!RLAZYw01H}q(+D= zTy$gw_0A@12n;l6094=Pv`j~g0k zfbuwHz?CK_tN}?3sVTC7Ur{Ood^QEF0%VdeVrn!Y-myOgMh}<6%m+CrE6WJPEW@;i z9427}ggLBlvjrc*39pV&E;__i)MkDK)bSD9$00t@DU6JZPW#FSS6LD z8_A)Gc7R6|C?ukNnn5~Dkc2|unWa;xJYxx)MEF@AtMG}*HfwHLq1Q?IfMXF7)hG83 ze&9m@)b28<52B~o8VK01F&&{c)S}RcrqK?oghq6jk3r4AT|u!5LFmw@;1i-B&|ksF zQZp#5u$}^}4j&(n;SY#z$;YyK@RjzvlF|tQjMs-E3*iC2)n$KRs*H_+<0Fj96vKl6 zsT3N^)aOYc#8}{HZW~B5A2ElpIF??|YN8NBq|#eQ4m440H3HqUV^azP_;$CI(D2o; z*cNVmygPgsNR&yunO`bPwS#u3r?6rM3KQ(8Kw&OHFE`T>h8MgVC0I9LytGkHu10% z1M8|O-ivfO2Xq8*W*6W`s1~M52oDReZvh^J*+~69fk?km>>rWGb|6>@-Ks+v0wIr) zN0J7T7USh>Shx#~u^2P!zfA{VQW$)KUQ)Y8?9!nuGBjAOtSZ96M?^cte&W0GZ znF_cAM#OpSvDOL8GtjAR`XHAl?brZi6qZ`j{#c_CvJQ)Etm)frQY{vvA zU>^xeV)oar@)4m-Yio?z38{y!17k8FO&Q}k=(t796c8+N!oC5OmmYn0iUoyO@^Y1!*msjaaf6P*+!VLLCfw62rD4Sof+!g@Tmm#A)XGf zhTCTXOu^(#Y_6RJ*`QX)8J9%3{?d3f0LcmPUmW0zL(~C~LPT{CpU0dXC!r|G)c1WbOZ+&q`z-|foV8| zoe_K>^e7~f*;N3sbmZyAoSh&rok9cZ&{vw}n8ll@-8N-saK>gF?1HltIE*lP69toM zK%oX@VpOnaMhrVQ(W)!NaJEzAs#lgAd(EQY*+s_GM7wm@mbHwJJ?d&T`)tAxAZEA? zTkYvAv={W7qR!RFbYuIlu+-MH>zFOFZ|-Ox(NREG+QrSTkoJp$yOUr$xC0PO*HHtj zx6EB!jWA0!usao9L`C89X_uk1QFtoNu0#og)qpD?YBUbj*YI_0wL^$&j1J}&khH{f zpe5HJj>9C~2)opPR}yOl?|@a@Kx85D2~Z6nZ=^yWhgFK9vJ)WP5|Cm>pPw`9*sGjZqX51wu^vZUGcMjKnu7 zZ@^@6$2cKG*3S>f2+zQN3@wEG=HQkk*H*uo{4|AYgsedd0o^HJH#oD40}U5+W2_lA z^Qp^rWEK!r9)pzEHc$Ihfklv(H;sKx!X!qtfL*Ra_{eC&<~9M4AX1ItJBRE~Bgz89 z6833yPYt`HLE!N{t#Lr$SJi5WHNa}txUuYQe|3Z>K~t<2B<347t20N_=1P4NBn$a8 zW9AAf>}sc+c(QpTXdLr^C6sGm?;s%BwX`|5gKc9jFsas%q-ocT=3S%vV)+Nr3V2I( zx~z=f4!a}f_L7bO-FvU?yOe{BNpj~ZF^Boorr`WHBaG=~egn)FyCDzEBn&6;7^vN_ zk}wZoc>qLJO}k_RKc+^!IbHXspkU%yXCj!0kP*n_XRPaK*7p;oiAns@%uq@ghhqx5EQKZ`*au_SMTjd%3BUpb6%cqx zazrGXBM8=byD;HQyhoINM_UMEp@i}5;1rD78UldPIs}2*AM4F?X}|`+KCw-VnTDkC zk+h^u5$%5c?8ztb2_J?`5`PQuM>(#K#V@`zgQNYAYD6A=WbB*t``G&Dz2E)b_rCz4 Carhtr literal 0 HcmV?d00001 diff --git a/apps/api/src/assets/logo.png b/apps/api/src/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..789f97fdc98fcf8ad76b15aa9f3d79c764052c35 GIT binary patch literal 16509 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 literal 0 HcmV?d00001 diff --git a/apps/api/src/assets/qr-logo.jpg b/apps/api/src/assets/qr-logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..96a94a982b5e8b9d6186650fec5a202069ef3c23 GIT binary patch literal 57854 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 q-NBFX5c<;{iH|LD=s)`SpNgx3 literal 0 HcmV?d00001 diff --git a/apps/api/src/assets/views/email/base-template.ejs b/apps/api/src/assets/views/email/base-template.ejs new file mode 100644 index 000000000..ed8f7ba46 --- /dev/null +++ b/apps/api/src/assets/views/email/base-template.ejs @@ -0,0 +1,407 @@ + + + + + + + <%= subject %> + + + + + <%- htmlContent %> + + + + + + + + + + \ No newline at end of file diff --git a/apps/api/src/discord.ts b/apps/api/src/discord.ts new file mode 100644 index 000000000..400977fd0 --- /dev/null +++ b/apps/api/src/discord.ts @@ -0,0 +1,21 @@ +import { Client, GatewayIntentBits, Partials, PermissionFlagsBits } from 'discord.js'; +import { BOT_TOKEN } from '@thxnetwork/api/config/secrets'; +import { eventRegister } from '@thxnetwork/api/util/discord'; +import { logger } from './app/util/logger'; +import eventRouter from '@thxnetwork/api/events'; + +export const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessageReactions], + partials: [Partials.Message, Partials.Channel, Partials.Reaction], +}); + +export default async () => { + try { + eventRegister(client, eventRouter); + client.login(BOT_TOKEN); + } catch (error) { + logger.error(error); + } +}; + +export { PermissionFlagsBits }; diff --git a/apps/api/src/environments/environment.prod.ts b/apps/api/src/environments/environment.prod.ts new file mode 100644 index 000000000..c9669790b --- /dev/null +++ b/apps/api/src/environments/environment.prod.ts @@ -0,0 +1,3 @@ +export const environment = { + production: true, +}; diff --git a/apps/api/src/environments/environment.ts b/apps/api/src/environments/environment.ts new file mode 100644 index 000000000..a20cfe557 --- /dev/null +++ b/apps/api/src/environments/environment.ts @@ -0,0 +1,3 @@ +export const environment = { + production: false, +}; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 000000000..996f83b12 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,71 @@ +import 'newrelic'; +import http from 'http'; +import https from 'https'; +import httpProxy from 'http-proxy'; +import app from './app'; +import discordBot from './discord'; +import db from './app/util/database'; +import { createTerminus } from '@godaddy/terminus'; +import { healthCheck } from './app/util/healthcheck'; +import { logger } from './app/util/logger'; +import { agenda } from './app/util/agenda'; +import fs from 'fs'; +import { LOCAL_CERT, LOCAL_CERT_KEY, NODE_ENV } from './app/config/secrets'; +import path from 'path'; + +let server: http.Server; + +if (LOCAL_CERT && LOCAL_CERT_KEY) { + const ssl = { + key: fs.readFileSync(path.resolve(path.dirname(__dirname), LOCAL_CERT_KEY)), + cert: fs.readFileSync(path.resolve(path.dirname(__dirname), LOCAL_CERT)), + }; + server = https.createServer(ssl, app); + httpProxy + .createServer({ + target: { + host: 'localhost', + port: 8545, + }, + ssl, + }) + .listen(8547); +} else { + server = http.createServer(app); +} + +const options = { + healthChecks: { + '/healthcheck': healthCheck, + 'verbatim': true, + }, + onSignal: () => { + logger.info('Server shutting down gracefully'); + return Promise.all([db.disconnect(), agenda.stop()]); + }, + logger: logger.error, +}; + +createTerminus(server, options); + +process.on('uncaughtException', function (err: Error) { + if (err) { + logger.error({ + message: 'Uncaught Exception was thrown, shutting down', + errorName: err.name, + errorMessage: err.message, + stack: err.stack, + }); + process.exit(1); + } +}); + +logger.info({ + message: `Server is starting on port: ${app.get('port')}, env: ${NODE_ENV}`, + port: app.get('port'), + env: NODE_ENV, +}); + +server.listen(app.get('port')); + +discordBot(); diff --git a/apps/api/tsconfig.app.json b/apps/api/tsconfig.app.json new file mode 100644 index 000000000..bf0e97eb3 --- /dev/null +++ b/apps/api/tsconfig.app.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["node"], + // Custom + "noImplicitAny": false, + "strict": false + }, + "include": [ + "src/**/*.ts", + "src/app/contracts/abis/**/*.json", + "../../libs/common/src/**/*", + "../../libs/sdk/src/**/*" + ], + "exclude": ["jest.config.ts", "src/**/*.test.ts", "../../libs/common/src/lib/scss/**/*"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 000000000..d09eefb8c --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "compilerOptions": { + "esModuleInterop": true + } +} diff --git a/apps/api/tsconfig.spec.json b/apps/api/tsconfig.spec.json new file mode 100644 index 000000000..c354ed639 --- /dev/null +++ b/apps/api/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/apps/api/webpack.config.js b/apps/api/webpack.config.js new file mode 100644 index 000000000..29f653d71 --- /dev/null +++ b/apps/api/webpack.config.js @@ -0,0 +1,13 @@ +const { composePlugins, withNx } = require('@nx/webpack'); + +// Nx plugins for webpack. +module.exports = composePlugins( + withNx({ + target: 'node', + }), + (config) => { + // Update the webpack config as needed here. + // e.g. `config.plugins.push(new MyPlugin())` + return config; + }, +); diff --git a/apps/app/project.json b/apps/app/project.json index c8fc90eb4..4b9c0967f 100644 --- a/apps/app/project.json +++ b/apps/app/project.json @@ -3,6 +3,7 @@ "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/app/src", "projectType": "application", + "tags": [], "targets": { "build": { "executor": "@nx/vite:build", @@ -37,6 +38,5 @@ "vitestConfig": "apps/app/vitest.config.ts" } } - }, - "tags": [] + } } diff --git a/apps/app/src/components/button/BaseButtonApprove.vue b/apps/app/src/components/button/BaseButtonApprove.vue index 3360b7153..d7219f170 100644 --- a/apps/app/src/components/button/BaseButtonApprove.vue +++ b/apps/app/src/components/button/BaseButtonApprove.vue @@ -12,7 +12,7 @@ import { formatUnits, parseUnits } from 'ethers/lib/utils'; import { useWalletStore } from '@thxnetwork/app/stores/Wallet'; import { mapStores } from 'pinia'; import { contractNetworks } from '@thxnetwork/app/config/constants'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import poll from 'promise-poller'; export default defineComponent({ diff --git a/apps/app/src/components/button/BaseButtonLiquidityCreate.vue b/apps/app/src/components/button/BaseButtonLiquidityCreate.vue index bfa87721e..e42e0cd97 100644 --- a/apps/app/src/components/button/BaseButtonLiquidityCreate.vue +++ b/apps/app/src/components/button/BaseButtonLiquidityCreate.vue @@ -11,7 +11,7 @@ import { BigNumber } from 'ethers/lib/ethers'; import { useWalletStore } from '@thxnetwork/app/stores/Wallet'; import { mapStores } from 'pinia'; import { BALANCER_POOL_ID, contractNetworks } from '@thxnetwork/app/config/constants'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { BalancerSDK, Network } from '@balancer-labs/sdk'; import { POLYGON_RPC } from '@thxnetwork/app/config/secrets'; import { useLiquidityStore } from '@thxnetwork/app/stores/Liquidity'; diff --git a/apps/app/src/components/button/BaseButtonLiquidityStake.vue b/apps/app/src/components/button/BaseButtonLiquidityStake.vue index cce87394a..ad416942e 100644 --- a/apps/app/src/components/button/BaseButtonLiquidityStake.vue +++ b/apps/app/src/components/button/BaseButtonLiquidityStake.vue @@ -11,7 +11,7 @@ import { BigNumber } from 'ethers/lib/ethers'; import { useWalletStore } from '@thxnetwork/app/stores/Wallet'; import { mapStores } from 'pinia'; import { contractNetworks } from '@thxnetwork/app/config/constants'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { useLiquidityStore } from '@thxnetwork/app/stores/Liquidity'; export default defineComponent({ diff --git a/apps/app/src/components/card/BaseCardMembershipOnboarding.vue b/apps/app/src/components/card/BaseCardMembershipOnboarding.vue index 3e7464482..9c12ad186 100644 --- a/apps/app/src/components/card/BaseCardMembershipOnboarding.vue +++ b/apps/app/src/components/card/BaseCardMembershipOnboarding.vue @@ -90,7 +90,7 @@ import { useLiquidityStore } from '../../stores/Liquidity'; import { useVeStore } from '../../stores/VE'; import { contractNetworks } from '@thxnetwork/app/config/constants'; import { chainList } from '@thxnetwork/app/utils/chains'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { formatUnits } from 'ethers/lib/utils'; import { useAuthStore } from '@thxnetwork/app/stores/Auth'; import { useAccountStore } from '../../stores/Account'; diff --git a/apps/app/src/components/card/BaseCardQuestGitcoin.vue b/apps/app/src/components/card/BaseCardQuestGitcoin.vue index 15dcabd84..f9bf004cd 100644 --- a/apps/app/src/components/card/BaseCardQuestGitcoin.vue +++ b/apps/app/src/components/card/BaseCardQuestGitcoin.vue @@ -57,8 +57,8 @@ import { useAccountStore } from '../../stores/Account'; import { useAuthStore } from '../../stores/Auth'; import { useQuestStore } from '../../stores/Quest'; import { useWalletStore } from '../../stores/Wallet'; -import { ChainId } from '@thxnetwork/sdk'; -import { WalletVariant } from '@thxnetwork/app/types/enums/accountVariant'; +import { WalletVariant } from '../../types/enums/accountVariant'; +import { ChainId } from '@thxnetwork/common/enums'; import imgLogoGitcoin from '../../assets/gitcoin-logo.svg'; export default defineComponent({ diff --git a/apps/app/src/components/card/BaseCardQuestWeb3.vue b/apps/app/src/components/card/BaseCardQuestWeb3.vue index dff8a0fda..46644a481 100644 --- a/apps/app/src/components/card/BaseCardQuestWeb3.vue +++ b/apps/app/src/components/card/BaseCardQuestWeb3.vue @@ -66,7 +66,7 @@ import { useAccountStore } from '../../stores/Account'; import { useAuthStore } from '../../stores/Auth'; import { useQuestStore } from '../../stores/Quest'; import { chainList, getAddressURL } from '../../utils/chains'; -import { ChainId } from '@thxnetwork/sdk/src/lib/types/enums/ChainId'; +import { ChainId } from '@thxnetwork/common/enums'; import { useWalletStore } from '@thxnetwork/app/stores/Wallet'; import { WalletVariant } from '@thxnetwork/app/types/enums/accountVariant'; diff --git a/apps/app/src/components/dropdown/BaseDropdownMetricReward.vue b/apps/app/src/components/dropdown/BaseDropdownMetricReward.vue index d2e0357e1..ab04a3d46 100644 --- a/apps/app/src/components/dropdown/BaseDropdownMetricReward.vue +++ b/apps/app/src/components/dropdown/BaseDropdownMetricReward.vue @@ -33,7 +33,7 @@ import { useWalletStore } from '../../stores/Wallet'; import { formatUnits } from 'ethers/lib/utils'; import { toFiatPrice } from '@thxnetwork/app/utils/price'; import { contractNetworks } from '@thxnetwork/app/config/constants'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { chainList } from '@thxnetwork/app/utils/chains'; type TMetricReward = { diff --git a/apps/app/src/components/dropdown/BaseDropdownMetricRewards.vue b/apps/app/src/components/dropdown/BaseDropdownMetricRewards.vue index ae93f540e..c4fd38a26 100644 --- a/apps/app/src/components/dropdown/BaseDropdownMetricRewards.vue +++ b/apps/app/src/components/dropdown/BaseDropdownMetricRewards.vue @@ -35,7 +35,7 @@ import { toFiatPrice } from '@thxnetwork/app/utils/price'; import { BigNumber } from 'ethers/lib/ethers'; import { chainList } from '@thxnetwork/app/utils/chains'; import { useWalletStore } from '@thxnetwork/app/stores/Wallet'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { contractNetworks } from '@thxnetwork/app/config/constants'; export default defineComponent({ diff --git a/apps/app/src/components/dropdown/BaseDropdownMetricTVL.vue b/apps/app/src/components/dropdown/BaseDropdownMetricTVL.vue index 2486a3929..a1e4b4d30 100644 --- a/apps/app/src/components/dropdown/BaseDropdownMetricTVL.vue +++ b/apps/app/src/components/dropdown/BaseDropdownMetricTVL.vue @@ -42,7 +42,7 @@ import { useWalletStore } from '../../stores/Wallet'; import { formatUnits } from 'ethers/lib/utils'; import { toFiatPrice } from '@thxnetwork/app/utils/price'; import { contractNetworks } from '@thxnetwork/app/config/constants'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { chainList } from '@thxnetwork/app/utils/chains'; export default defineComponent({ diff --git a/apps/app/src/components/formgroup/BaseFormGroupLockAmount.vue b/apps/app/src/components/formgroup/BaseFormGroupLockAmount.vue index e0b39a595..a96a0f58a 100644 --- a/apps/app/src/components/formgroup/BaseFormGroupLockAmount.vue +++ b/apps/app/src/components/formgroup/BaseFormGroupLockAmount.vue @@ -36,7 +36,7 @@ import { contractNetworks } from '@thxnetwork/app/config/constants'; import { useLiquidityStore } from '@thxnetwork/app/stores/Liquidity'; import { useVeStore } from '@thxnetwork/app/stores/VE'; import { useWalletStore } from '@thxnetwork/app/stores/Wallet'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { formatUnits, parseUnits } from 'ethers/lib/utils'; import { mapStores } from 'pinia'; import { defineComponent } from 'vue'; diff --git a/apps/app/src/components/modal/BaseModalClaimTokens.vue b/apps/app/src/components/modal/BaseModalClaimTokens.vue index ccfb41c81..19af3f7b1 100644 --- a/apps/app/src/components/modal/BaseModalClaimTokens.vue +++ b/apps/app/src/components/modal/BaseModalClaimTokens.vue @@ -71,7 +71,7 @@ import { useVeStore } from '../../stores/VE'; import { useWalletStore } from '../../stores/Wallet'; import { formatUnits } from 'ethers/lib/utils'; import { roundDownFixed, toFiatPrice } from '@thxnetwork/app/utils/price'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { chainList } from '@thxnetwork/app/utils/chains'; import { useLiquidityStore } from '@thxnetwork/app/stores/Liquidity'; import { WalletVariant } from '@thxnetwork/app/types/enums/accountVariant'; diff --git a/apps/app/src/components/modal/BaseModalCreateLiquidity.vue b/apps/app/src/components/modal/BaseModalCreateLiquidity.vue index 5ad8ef037..6c27abc4a 100644 --- a/apps/app/src/components/modal/BaseModalCreateLiquidity.vue +++ b/apps/app/src/components/modal/BaseModalCreateLiquidity.vue @@ -125,7 +125,7 @@ import { useWalletStore } from '../../stores/Wallet'; import { useLiquidityStore } from '../../stores/Liquidity'; import { BALANCER_POOL_ID, contractNetworks } from '../../config/constants'; import { parseUnits } from 'ethers/lib/utils'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { WalletVariant } from '@thxnetwork/app/types/enums/accountVariant'; import { BalancerSDK, Network } from '@balancer-labs/sdk'; import { POLYGON_RPC } from '@thxnetwork/app/config/secrets'; diff --git a/apps/app/src/components/modal/BaseModalDeposit.vue b/apps/app/src/components/modal/BaseModalDeposit.vue index c05c7db64..9c7d11705 100644 --- a/apps/app/src/components/modal/BaseModalDeposit.vue +++ b/apps/app/src/components/modal/BaseModalDeposit.vue @@ -52,7 +52,7 @@ import { useVeStore } from '../../stores/VE'; import { useWalletStore } from '../../stores/Wallet'; import { useLiquidityStore } from '../../stores/Liquidity'; import { contractNetworks } from '../../config/constants'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { formatUnits, parseUnits } from 'ethers/lib/utils'; import { roundDownFixed } from '@thxnetwork/app/utils/price'; import { WalletVariant } from '@thxnetwork/app/types/enums/accountVariant'; diff --git a/apps/app/src/components/modal/BaseModalIncreaseAmount.vue b/apps/app/src/components/modal/BaseModalIncreaseAmount.vue index 0f74afe15..91c57019d 100644 --- a/apps/app/src/components/modal/BaseModalIncreaseAmount.vue +++ b/apps/app/src/components/modal/BaseModalIncreaseAmount.vue @@ -38,7 +38,7 @@ import { useWalletStore } from '../../stores/Wallet'; import { useLiquidityStore } from '@thxnetwork/app/stores/Liquidity'; import { contractNetworks } from '../../config/constants'; import { formatUnits, parseUnits } from 'ethers/lib/utils'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { WalletVariant } from '@thxnetwork/app/types/enums/accountVariant'; import { BigNumber } from 'ethers/lib/ethers'; diff --git a/apps/app/src/components/modal/BaseModalMembershipCreate.vue b/apps/app/src/components/modal/BaseModalMembershipCreate.vue index d79f99825..8db612185 100644 --- a/apps/app/src/components/modal/BaseModalMembershipCreate.vue +++ b/apps/app/src/components/modal/BaseModalMembershipCreate.vue @@ -135,7 +135,7 @@ import { useVeStore } from '../../stores/VE'; import { useWalletStore } from '../../stores/Wallet'; import { useLiquidityStore } from '../../stores/Liquidity'; import { contractNetworks } from '../../config/constants'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { formatUnits, parseUnits } from 'ethers/lib/utils'; import { chainList } from '@thxnetwork/app/utils/chains'; import { BigNumber } from 'ethers/lib/ethers'; diff --git a/apps/app/src/components/modal/BaseModalStake.vue b/apps/app/src/components/modal/BaseModalStake.vue index b200e4639..4c719a747 100644 --- a/apps/app/src/components/modal/BaseModalStake.vue +++ b/apps/app/src/components/modal/BaseModalStake.vue @@ -45,7 +45,7 @@ import { useWalletStore } from '../../stores/Wallet'; import { useLiquidityStore } from '../../stores/Liquidity'; import { contractNetworks } from '../../config/constants'; import { formatUnits, parseUnits } from 'ethers/lib/utils'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { WalletVariant } from '@thxnetwork/app/types/enums/accountVariant'; export default defineComponent({ diff --git a/apps/app/src/components/modal/BaseModalTokenSelect.vue b/apps/app/src/components/modal/BaseModalTokenSelect.vue index 70e35aa4f..e9177a0ad 100644 --- a/apps/app/src/components/modal/BaseModalTokenSelect.vue +++ b/apps/app/src/components/modal/BaseModalTokenSelect.vue @@ -43,7 +43,7 @@ import { useWalletStore } from '../../stores/Wallet'; import { toFiatPrice } from '@thxnetwork/app/utils/price'; import { useLiquidityStore } from '@thxnetwork/app/stores/Liquidity'; import { contractNetworks } from '@thxnetwork/app/config/constants'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { chainList } from '@thxnetwork/app/utils/chains'; import { formatUnits } from 'ethers/lib/utils'; diff --git a/apps/app/src/components/modal/BaseModalWalletConnect.vue b/apps/app/src/components/modal/BaseModalWalletConnect.vue index e3def111c..7986f0741 100644 --- a/apps/app/src/components/modal/BaseModalWalletConnect.vue +++ b/apps/app/src/components/modal/BaseModalWalletConnect.vue @@ -47,7 +47,7 @@ import { useWalletStore } from '../../stores/Wallet'; import { mapStores } from 'pinia'; import { defineComponent } from 'vue'; import { useAccountStore } from '@thxnetwork/app/stores/Account'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { shortenAddress } from '@thxnetwork/app/utils/address'; export default defineComponent({ diff --git a/apps/app/src/components/tabs/BaseTabDeposit.vue b/apps/app/src/components/tabs/BaseTabDeposit.vue index 56137cbcf..56f09000f 100644 --- a/apps/app/src/components/tabs/BaseTabDeposit.vue +++ b/apps/app/src/components/tabs/BaseTabDeposit.vue @@ -41,7 +41,7 @@ import { useLiquidityStore } from '../../stores/Liquidity'; import { useVeStore } from '../../stores/VE'; import { contractNetworks } from '../../config/constants'; import { NinetyDaysInMs, getThursdaysUntilTimestamp } from '../../utils/date'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { formatUnits, parseUnits } from 'ethers/lib/utils'; import { useAuthStore } from '@thxnetwork/app/stores/Auth'; import { BigNumber } from 'ethers/lib/ethers'; diff --git a/apps/app/src/components/tabs/BaseTabLiquidity.vue b/apps/app/src/components/tabs/BaseTabLiquidity.vue index 9bf4f6062..1f2ed1686 100644 --- a/apps/app/src/components/tabs/BaseTabLiquidity.vue +++ b/apps/app/src/components/tabs/BaseTabLiquidity.vue @@ -217,7 +217,7 @@ import { useWalletStore } from '../../stores/Wallet'; import { useLiquidityStore } from '../../stores/Liquidity'; import { useVeStore } from '../../stores/VE'; import { contractNetworks } from '../../config/constants'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { formatUnits, parseUnits } from 'ethers/lib/utils'; import { useAuthStore } from '@thxnetwork/app/stores/Auth'; import { chainList } from '@thxnetwork/app/utils/chains'; diff --git a/apps/app/src/components/tabs/BaseTabWithdraw.vue b/apps/app/src/components/tabs/BaseTabWithdraw.vue index 465a7e0f4..25544c89f 100644 --- a/apps/app/src/components/tabs/BaseTabWithdraw.vue +++ b/apps/app/src/components/tabs/BaseTabWithdraw.vue @@ -61,7 +61,7 @@ import { useVeStore } from '../../stores/VE'; import { format, differenceInDays } from 'date-fns'; import { contractNetworks } from '../../config/constants'; import { fromWei } from 'web3-utils'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; export default defineComponent({ name: 'BaseTabWithdraw', diff --git a/apps/app/src/main.ts b/apps/app/src/main.ts index e99b9c5d2..d2e3b05b6 100644 --- a/apps/app/src/main.ts +++ b/apps/app/src/main.ts @@ -1,13 +1,13 @@ import { BootstrapVueNext, vBTooltip } from 'bootstrap-vue-next'; import { createApp } from 'vue'; import { createPinia } from 'pinia'; -import { Sentry } from '@thxnetwork/common/lib/sentry'; +import { Sentry } from '@thxnetwork/common/sentry'; import { GCLOUD_RECAPTCHA_SITE_KEY, MODE, API_URL, MIXPANEL_TOKEN, AUTH_URL, WIDGET_URL } from './config/secrets'; import App from './App.vue'; import VueClipboard from 'vue3-clipboard'; import Vue3Toastify from 'vue3-toastify'; import router from './router'; -import Mixpanel from '@thxnetwork/common/lib/mixpanel'; +import Mixpanel from '@thxnetwork/common/mixpanel'; import './scss/main.scss'; diff --git a/apps/app/src/stores/Account.ts b/apps/app/src/stores/Account.ts index a0d9eab20..743fe426c 100644 --- a/apps/app/src/stores/Account.ts +++ b/apps/app/src/stores/Account.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia'; -import { track } from '@thxnetwork/common/lib/mixpanel'; -import { THXBrowserClient } from '@thxnetwork/sdk'; +import { track } from '@thxnetwork/common/mixpanel'; +import { THXBrowserClient } from '@thxnetwork/sdk/clients'; import { API_URL, AUTH_URL, CLIENT_ID, WIDGET_URL } from '../config/secrets'; import { DEFAULT_COLORS, DEFAULT_ELEMENTS, getStyles } from '../utils/theme'; import { BREAKPOINT_LG } from '../config/constants'; diff --git a/apps/app/src/stores/Auth.ts b/apps/app/src/stores/Auth.ts index daae0bca0..cadae11a5 100644 --- a/apps/app/src/stores/Auth.ts +++ b/apps/app/src/stores/Auth.ts @@ -5,7 +5,7 @@ import { tKey } from '../utils/tkey'; import { useAccountStore } from './Account'; import { User, UserManager, WebStorageStateStore } from 'oidc-client-ts'; import { Wallet } from '@ethersproject/wallet'; -import { track } from '@thxnetwork/common/lib/mixpanel'; +import { track } from '@thxnetwork/common/mixpanel'; import poll from 'promise-poller'; import { useVeStore } from './VE'; import { useWalletStore } from './Wallet'; diff --git a/apps/app/src/stores/Liquidity.ts b/apps/app/src/stores/Liquidity.ts index 7450e1ab5..1398fa9f9 100644 --- a/apps/app/src/stores/Liquidity.ts +++ b/apps/app/src/stores/Liquidity.ts @@ -6,7 +6,7 @@ import { BALANCER_POOL_ID, contractNetworks } from '../config/constants'; import { WalletVariant } from '../types/enums/accountVariant'; import { BigNumber } from 'ethers'; import { PoolWithMethods } from '@balancer-labs/sdk'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; type TCreateLiquidityOptions = { thxAmountInWei: string; diff --git a/apps/app/src/stores/QRCode.ts b/apps/app/src/stores/QRCode.ts index a3ab6a086..121ab2eca 100644 --- a/apps/app/src/stores/QRCode.ts +++ b/apps/app/src/stores/QRCode.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia'; import { useAccountStore } from './Account'; -import { track } from '@thxnetwork/common/lib/mixpanel'; +import { track } from '@thxnetwork/common/mixpanel'; export const useQRCodeStore = defineStore('qrcode', { state: (): TQRCodeState => ({ diff --git a/apps/app/src/stores/Quest.ts b/apps/app/src/stores/Quest.ts index dd73fcf9a..068c4a802 100644 --- a/apps/app/src/stores/Quest.ts +++ b/apps/app/src/stores/Quest.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia'; import { useAccountStore } from './Account'; -import { track } from '@thxnetwork/common/lib/mixpanel'; -import { QuestVariant } from '@thxnetwork/sdk'; +import { track } from '@thxnetwork/common/mixpanel'; +import { QuestVariant } from '@thxnetwork/common/enums'; import { GCLOUD_RECAPTCHA_SITE_KEY } from '../config/secrets'; export const useQuestStore = defineStore('quest', { diff --git a/apps/app/src/stores/Reward.ts b/apps/app/src/stores/Reward.ts index 37c12da7d..d18479c1e 100644 --- a/apps/app/src/stores/Reward.ts +++ b/apps/app/src/stores/Reward.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia'; -import { track } from '@thxnetwork/common/lib/mixpanel'; +import { track } from '@thxnetwork/common/mixpanel'; import { toNumber } from '../utils/quests'; import { useAccountStore } from './Account'; import { RewardVariant } from '../types/enums/rewards'; diff --git a/apps/app/src/stores/VE.ts b/apps/app/src/stores/VE.ts index d1ac069d4..acd8b5b61 100644 --- a/apps/app/src/stores/VE.ts +++ b/apps/app/src/stores/VE.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia'; import { useAccountStore } from './Account'; import { useWalletStore } from './Wallet'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; import { MODE } from '../config/secrets'; import { WalletVariant } from '../types/enums/accountVariant'; import { contractNetworks } from '../config/constants'; diff --git a/apps/app/src/stores/Wallet.ts b/apps/app/src/stores/Wallet.ts index 59d3b4bf1..18f4f86a7 100644 --- a/apps/app/src/stores/Wallet.ts +++ b/apps/app/src/stores/Wallet.ts @@ -1,11 +1,11 @@ import { defineStore } from 'pinia'; import { useAccountStore } from './Account'; -import { track } from '@thxnetwork/common/lib/mixpanel'; +import { track } from '@thxnetwork/common/mixpanel'; import { HARDHAT_RPC, POLYGON_RPC } from '../config/secrets'; import { useAuthStore } from './Auth'; import { EthersAdapter, SafeConfig } from '@safe-global/protocol-kit'; import { ethers } from 'ethers'; -import { ChainId } from '@thxnetwork/sdk/src/lib/types/enums/ChainId'; +import { ChainId } from '@thxnetwork/common/enums'; import { WalletVariant } from '../types/enums/accountVariant'; import { AUTH_URL, WALLET_CONNECT_PROJECT_ID, WIDGET_URL } from '../config/secrets'; import { createWeb3Modal, defaultWagmiConfig } from '@web3modal/wagmi'; diff --git a/apps/app/src/utils/chains.ts b/apps/app/src/utils/chains.ts index 2a3ebb9cf..d303a6b58 100644 --- a/apps/app/src/utils/chains.ts +++ b/apps/app/src/utils/chains.ts @@ -5,7 +5,7 @@ import ImgLogoPolygon from '../assets/thx_logo_polygon.svg'; import ImgLogoHardhat from '../assets/thx_logo_hardhat.svg'; import ImgLogoLinea from '../assets/thx_logo_linea.svg'; import { arbitrum, mainnet, bsc, polygon, hardhat, polygonZkEvm, linea } from '@wagmi/core/chains'; -import { ChainId } from '@thxnetwork/sdk'; +import { ChainId } from '@thxnetwork/common/enums'; const chainList: { [chainId: number]: ChainInfo } = { [ChainId.Ethereum]: { diff --git a/apps/app/src/views/Campaign.vue b/apps/app/src/views/Campaign.vue index f0bda3073..07f656304 100644 --- a/apps/app/src/views/Campaign.vue +++ b/apps/app/src/views/Campaign.vue @@ -17,7 +17,7 @@