From bb84099dfbc3aa77b42c8f60d2a477434a0f07d1 Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 23 May 2024 09:08:45 +0200 Subject: [PATCH] Adds supply limit --- .github/workflows/app.yml | 2 +- .../app/controllers/account/get.controller.ts | 2 - .../erc721/transfer/erc721-transfer.test.ts | 2 +- .../app/controllers/pools/get.controller.ts | 3 +- .../app/controllers/pools/post.controller.ts | 5 +- .../app/controllers/qr-codes/qr-codes.test.ts | 2 +- apps/api/src/app/hardhat/scripts/deploy.ts | 3 + .../migrations/20240522145618-supply-limit.js | 14 +++ apps/api/src/app/models/Reward.ts | 1 + apps/api/src/app/services/RewardNFTService.ts | 11 ++- apps/api/src/app/services/RewardService.ts | 95 +++++++++++++------ apps/api/src/app/services/SafeService.ts | 6 +- apps/api/src/app/services/WalletService.ts | 10 +- .../src/components/card/BaseCardReward.vue | 85 ++++++++++++----- .../modal/BaseModalRewardPayment.vue | 5 +- apps/app/src/scss/_variables.scss | 2 +- apps/app/src/stores/Account.ts | 2 +- apps/app/src/stores/Reward.ts | 3 +- apps/app/src/types/interfaces/rewards.d.ts | 17 +++- apps/app/src/views/discovery/Members.vue | 24 +++++ libs/common/src/lib/types/Reward.d.ts | 1 + 21 files changed, 214 insertions(+), 81 deletions(-) create mode 100644 apps/api/src/app/migrations/20240522145618-supply-limit.js diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index 6372208b4..55d6c03d8 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -156,4 +156,4 @@ jobs: DISCORD_WEBHOOK: ${{ env.DISCORD_WEBHOOK }} uses: Ilshidur/action-discord@master with: - args: "${{ needs.autodeploy.result == 'success' && '✅' || '⛔' }} Released App ${{ env.PACKAGE_VERSION }}" + args: "${{ needs.autodeploy.result == 'success' && '✅' || '⛔' }} Released App `${{ env.PACKAGE_VERSION }}`" diff --git a/apps/api/src/app/controllers/account/get.controller.ts b/apps/api/src/app/controllers/account/get.controller.ts index 82c5ae188..d8c5d18dc 100644 --- a/apps/api/src/app/controllers/account/get.controller.ts +++ b/apps/api/src/app/controllers/account/get.controller.ts @@ -3,7 +3,6 @@ import { WalletVariant, AccountVariant } from '@thxnetwork/common/enums'; import AccountProxy from '@thxnetwork/api/proxies/AccountProxy'; import WalletService from '@thxnetwork/api/services/WalletService'; import THXService from '@thxnetwork/api/services/THXService'; -import ContractService from '@thxnetwork/api/services/ContractService'; const validation = []; @@ -28,7 +27,6 @@ const controller = async (req: Request, res: Response) => { await WalletService.createWalletConnect({ sub: req.auth.sub, address: account.address, - chainId: ContractService.getChainId(), }); } } 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 index 451fc22c6..140ff132e 100644 --- a/apps/api/src/app/controllers/erc721/transfer/erc721-transfer.test.ts +++ b/apps/api/src/app/controllers/erc721/transfer/erc721-transfer.test.ts @@ -47,7 +47,7 @@ describe('ERC721 Transfer', () => { 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) }); + const safe = await SafeService.create({ sub, safeVersion, poolId: String(pool._id) }); // Wait for safe address to return code await poll( diff --git a/apps/api/src/app/controllers/pools/get.controller.ts b/apps/api/src/app/controllers/pools/get.controller.ts index ab7b97c81..7a62adfc6 100644 --- a/apps/api/src/app/controllers/pools/get.controller.ts +++ b/apps/api/src/app/controllers/pools/get.controller.ts @@ -13,12 +13,11 @@ 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); + let safe = await SafeService.findOneByPool(pool); // Deploy a Safe if none is found if (!safe) { safe = await SafeService.create({ - chainId: pool.chainId, sub: pool.sub, safeVersion, poolId: req.params.id, diff --git a/apps/api/src/app/controllers/pools/post.controller.ts b/apps/api/src/app/controllers/pools/post.controller.ts index 1a9f6ae68..a98e80265 100644 --- a/apps/api/src/app/controllers/pools/post.controller.ts +++ b/apps/api/src/app/controllers/pools/post.controller.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { body } from 'express-validator'; -import ContractService, { safeVersion } from '@thxnetwork/api/services/ContractService'; +import { safeVersion } from '@thxnetwork/api/services/ContractService'; import PoolService from '@thxnetwork/api/services/PoolService'; import SafeService from '@thxnetwork/api/services/SafeService'; @@ -12,8 +12,7 @@ const controller = async (req: Request, res: Response) => { // Deploy a Safe for the campaign const poolId = String(pool._id); - const chainId = ContractService.getChainId(); - const safe = await SafeService.create({ chainId, sub: req.auth.sub, safeVersion, poolId }); + const safe = await SafeService.create({ sub: req.auth.sub, safeVersion, poolId }); // Update predicted safe address for pool await pool.updateOne({ safeAddress: safe.address }); 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 index d4d5bf15c..574c9e082 100644 --- a/apps/api/src/app/controllers/qr-codes/qr-codes.test.ts +++ b/apps/api/src/app/controllers/qr-codes/qr-codes.test.ts @@ -39,7 +39,7 @@ describe('QR Codes', () => { pool = await PoolService.deploy(sub, 'My Reward Campaign'); poolId = String(pool._id); - const safe = await SafeService.create({ sub, chainId, safeVersion, poolId }); + const safe = await SafeService.create({ sub, safeVersion, poolId }); // Wait for campaign safe to be deployed const { web3 } = getProvider(ChainId.Hardhat); diff --git a/apps/api/src/app/hardhat/scripts/deploy.ts b/apps/api/src/app/hardhat/scripts/deploy.ts index a84ddbdd1..a2935347f 100644 --- a/apps/api/src/app/hardhat/scripts/deploy.ts +++ b/apps/api/src/app/hardhat/scripts/deploy.ts @@ -111,6 +111,9 @@ async function main() { // Deploy PaymentSplitter const splitter = await deploy('THXPaymentSplitter', [await signer.getAddress(), registry.address], signer); await splitter.setRegistry(registry.address); + + // Skip 7 days + await hre.network.provider.send('evm_increaseTime', [60 * 60 * 24 * 7]); } main().catch(console.error); diff --git a/apps/api/src/app/migrations/20240522145618-supply-limit.js b/apps/api/src/app/migrations/20240522145618-supply-limit.js new file mode 100644 index 000000000..067c34220 --- /dev/null +++ b/apps/api/src/app/migrations/20240522145618-supply-limit.js @@ -0,0 +1,14 @@ +module.exports = { + async up(db) { + await db.collection('rewardcoin').updateMany({}, [{ $set: { limitSupply: '$limit', limit: 0 } }]); + await db.collection('rewardnft').updateMany({}, [{ $set: { limitSupply: '$limit', limit: 0 } }]); + await db.collection('rewardcoupon').updateMany({}, [{ $set: { limitSupply: '$limit', limit: 0 } }]); + await db.collection('rewardcustom').updateMany({}, [{ $set: { limitSupply: '$limit', limit: 0 } }]); + await db.collection('rewarddiscordrole').updateMany({}, [{ $set: { limitSupply: '$limit', limit: 0 } }]); + await db.collection('rewardgalachain').updateMany({}, [{ $set: { limitSupply: '$limit', limit: 0 } }]); + }, + + async down() { + // + }, +}; diff --git a/apps/api/src/app/models/Reward.ts b/apps/api/src/app/models/Reward.ts index 7301a1d31..54fa7afb8 100644 --- a/apps/api/src/app/models/Reward.ts +++ b/apps/api/src/app/models/Reward.ts @@ -7,6 +7,7 @@ export const rewardSchema = { pointPrice: Number, expiryDate: Date, limit: Number, + limitSupply: Number, isPromoted: { type: Boolean, default: false }, isPublished: { type: Boolean, default: false }, locks: { type: [{ questId: String, variant: Number }], default: [] }, diff --git a/apps/api/src/app/services/RewardNFTService.ts b/apps/api/src/app/services/RewardNFTService.ts index d6c64139a..51907ccc2 100644 --- a/apps/api/src/app/services/RewardNFTService.ts +++ b/apps/api/src/app/services/RewardNFTService.ts @@ -63,16 +63,21 @@ export default class RewardNFTService implements IRewardService { 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.' }; + return { result: false, reason: 'Token is no longer owned by campaign Safe.' }; } } - // Will require a mint + // This 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 if wallet exists + if (!wallet) { + return { result: false, reason: 'Your wallet is not found. Please try again.' }; + } + // 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.' }; @@ -117,11 +122,9 @@ export default class RewardNFTService implements IRewardService { // 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); } diff --git a/apps/api/src/app/services/RewardService.ts b/apps/api/src/app/services/RewardService.ts index aa6560118..c15c10ab4 100644 --- a/apps/api/src/app/services/RewardService.ts +++ b/apps/api/src/app/services/RewardService.ts @@ -1,6 +1,6 @@ import { Document } from 'mongoose'; import { RewardVariant } from '@thxnetwork/common/enums'; -import { Participant, QRCodeEntry, WalletDocument } from '@thxnetwork/api/models'; +import { Participant, QRCodeEntry, Wallet, WalletDocument } from '@thxnetwork/api/models'; import { v4 } from 'uuid'; import { logger } from '../util/logger'; import { Job } from '@hokify/agenda'; @@ -42,6 +42,7 @@ export default class RewardService { } static async list({ pool, account }) { + const owner = await AccountProxy.findById(pool.sub); const rewardVariants = Object.keys(RewardVariant).filter((v) => !isNaN(Number(v))); const callback: any = async (variant: RewardVariant) => { const Reward = serviceMap[variant].models.reward; @@ -64,18 +65,43 @@ export default class RewardService { try { const decorated = await serviceMap[reward.variant].decorate({ reward, account }); const isLocked = await this.isLocked({ reward, account }); - const isStocked = await this.isStocked(reward); + const isLimitReached = await this.isLimitReached({ reward, account }); + const isLimitSupplyReached = await this.isLimitSupplyReached(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, + const isAvailable = account + ? !isLocked && !isExpired && !isLimitSupplyReached && !isLimitReached + : true; + const limit = { + count: account + ? await serviceMap[reward.variant].models.payment.countDocuments({ + rewardId: reward.id, + sub: account.sub, + }) + : 0, + max: reward.limit, + }; + const paymentCount = await serviceMap[reward.variant].models.payment.countDocuments({ + rewardId: reward.id, + }); + const limitSupply = { + count: paymentCount, + max: reward.limitSupply, + }; + return { + isLocked, + isLimitReached, + isLimitSupplyReached, + isExpired, + isAvailable, + paymentCount, + author: { + username: owner.username, + }, + // Decorated properties may override generic properties + ...decorated, + limit, + limitSupply, }; - - // Decorated properties may override generic properties - return { progress, isLocked, isStocked, isExpired, isAvailable, ...decorated }; } catch (error) { logger.error(error); } @@ -167,28 +193,26 @@ export default class RewardService { 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)); + const wallet = walletId && (await Wallet.findById(walletId)); // Validate supply, expiry, locked and reward specific validation - const validationResult = await this.getValidationResult({ reward, account, safe: pool.safe }); + const validationResult = await this.getValidationResult({ reward, account, wallet, safe: pool.safe }); if (!validationResult.result) return validationResult.reason; // Subtract points for account await PointBalanceService.subtract(pool, account, reward.pointPrice); + // Create the payment + await serviceMap[variant].createPayment({ reward, account, safe: pool.safe, wallet }); + // 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); @@ -240,7 +264,7 @@ export default class RewardService { wallet, }: { reward: TReward; - account?: TAccount; + account: TAccount; safe?: WalletDocument; wallet?: WalletDocument; }) { @@ -255,38 +279,53 @@ export default class RewardService { 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.' }; + const isLimitSupplyReached = await this.isLimitSupplyReached(reward); + if (isLimitSupplyReached) return { result: false, reason: "This reward has reached it's supply limit." }; + + const isLimitReached = await this.isLimitReached(reward); + if (isLimitReached) return { result: false, reason: 'This reward has reached your personal limit.' }; return serviceMap[reward.variant].getValidationResult({ reward, account, wallet, safe }); } + // Checks if the account has reached the max amount of payments for this reward + static async isLimitReached({ reward, account }: { reward: TReward; account?: TAccount }) { + if (!account || !reward.limit) return false; + const Payment = await serviceMap[reward.variant].models.payment; + const paymentCount = await Payment.countDocuments({ rewardId: reward.id, sub: account.sub }); + return paymentCount >= reward.limit; + } + + // Checks if the reward is locked with a quest static async isLocked({ reward, account }) { if (!account || !reward.locks.length) return false; return await LockService.getIsLocked(reward.locks, account); } + // Checks if the reward is expired 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; + // Checks if the reward supply is sufficient + static async isLimitSupplyReached(reward: TReward) { + if (!reward.limitSupply) return false; // Check if reward has a limit and if limit has been reached - const amountOfPayments = await serviceMap[reward.variant].models.payment.countDocuments({ - rewardId: reward._id, + const paymentCount = await serviceMap[reward.variant].models.payment.countDocuments({ + rewardId: reward.id, }); - return amountOfPayments < reward.limit; + return paymentCount >= reward.limitSupply; } 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 isLimitSupplyReached = await this.isLimitSupplyReached(reward); + const isLimitReached = await this.isLimitReached({ reward, account }); const isExpired = this.isExpired(reward); - return !isLocked && !isExpired && isStocked; + return !isLocked && !isExpired && !isLimitSupplyReached && !isLimitReached; } } diff --git a/apps/api/src/app/services/SafeService.ts b/apps/api/src/app/services/SafeService.ts index 608b2476e..f2a32fec7 100644 --- a/apps/api/src/app/services/SafeService.ts +++ b/apps/api/src/app/services/SafeService.ts @@ -8,7 +8,6 @@ import Safe, { SafeAccountConfig, SafeFactory } from '@safe-global/protocol-kit' import SafeApiKit from '@safe-global/api-kit'; import { SafeMultisigTransactionResponse, - SafeTransactionData, SafeTransactionDataPartial, SafeVersion, } from '@safe-global/safe-core-sdk-types'; @@ -29,10 +28,11 @@ function reset(wallet: WalletDocument, userWalletAddress: string) { } async function create( - data: { chainId: ChainId; sub: string; safeVersion?: SafeVersion; address?: string; poolId?: string }, + data: { sub: string; safeVersion?: SafeVersion; address?: string; poolId?: string }, userWalletAddress?: string, ) { - const { safeVersion, chainId, sub, address, poolId } = data; + const { safeVersion, sub, address, poolId } = data; + const chainId = ContractService.getChainId(); const { defaultAccount } = getProvider(chainId); const wallet = await Wallet.create({ variant: WalletVariant.Safe, sub, chainId, address, safeVersion, poolId }); diff --git a/apps/api/src/app/services/WalletService.ts b/apps/api/src/app/services/WalletService.ts index b8ede9c58..58ca8d1d4 100644 --- a/apps/api/src/app/services/WalletService.ts +++ b/apps/api/src/app/services/WalletService.ts @@ -41,24 +41,24 @@ export default class WalletService { } static create(variant: WalletVariant, data: Partial) { - const chainId = ContractService.getChainId(); const map = { [WalletVariant.Safe]: WalletService.createSafe, [WalletVariant.WalletConnect]: WalletService.createWalletConnect, }; - return map[variant]({ ...(data as TWallet), chainId }); + return map[variant]({ ...(data as TWallet) }); } - static async createSafe({ sub, address, chainId }) { + static async createSafe({ sub, address }) { 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); + await SafeService.create({ sub, safeVersion }, address); } - static async createWalletConnect({ sub, address, chainId }) { + static async createWalletConnect({ sub, address }) { + const chainId = ContractService.getChainId(); const data: Partial = { variant: WalletVariant.WalletConnect, sub, address, chainId }; await Wallet.findOneAndUpdate({ sub, address, chainId }, data, { upsert: true }); diff --git a/apps/app/src/components/card/BaseCardReward.vue b/apps/app/src/components/card/BaseCardReward.vue index 105443bf1..8a3d65d75 100644 --- a/apps/app/src/components/card/BaseCardReward.vue +++ b/apps/app/src/components/card/BaseCardReward.vue @@ -15,14 +15,14 @@