Skip to content

Commit

Permalink
Adds supply limit
Browse files Browse the repository at this point in the history
  • Loading branch information
peterpolman committed May 23, 2024
1 parent 8fcbab3 commit bb84099
Show file tree
Hide file tree
Showing 21 changed files with 214 additions and 81 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}`"
2 changes: 0 additions & 2 deletions apps/api/src/app/controllers/account/get.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand All @@ -28,7 +27,6 @@ const controller = async (req: Request, res: Response) => {
await WalletService.createWalletConnect({
sub: req.auth.sub,
address: account.address,
chainId: ContractService.getChainId(),
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 1 addition & 2 deletions apps/api/src/app/controllers/pools/get.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 2 additions & 3 deletions apps/api/src/app/controllers/pools/post.controller.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 });
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app/controllers/qr-codes/qr-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/app/hardhat/scripts/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
14 changes: 14 additions & 0 deletions apps/api/src/app/migrations/20240522145618-supply-limit.js
Original file line number Diff line number Diff line change
@@ -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() {
//
},
};
1 change: 1 addition & 0 deletions apps/api/src/app/models/Reward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },
Expand Down
11 changes: 7 additions & 4 deletions apps/api/src/app/services/RewardNFTService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.' };
Expand Down Expand Up @@ -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);
}
Expand Down
95 changes: 67 additions & 28 deletions apps/api/src/app/services/RewardService.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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 = `<p style="font-size: 18px">Congratulations!🚀</p>`;
html += `<p>Your payment has been received! <strong>${reward.title}</strong> is available in your account.</p>`;
html += `<p class="btn"><a href="${pool.campaignURL}">View Wallet</a></p>`;
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);
Expand Down Expand Up @@ -240,7 +264,7 @@ export default class RewardService {
wallet,
}: {
reward: TReward;
account?: TAccount;
account: TAccount;
safe?: WalletDocument;
wallet?: WalletDocument;
}) {
Expand All @@ -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;
}
}
6 changes: 3 additions & 3 deletions apps/api/src/app/services/SafeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 });

Expand Down
10 changes: 5 additions & 5 deletions apps/api/src/app/services/WalletService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,24 +41,24 @@ export default class WalletService {
}

static create(variant: WalletVariant, data: Partial<TWallet>) {
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<TWallet> = { variant: WalletVariant.WalletConnect, sub, address, chainId };

await Wallet.findOneAndUpdate({ sub, address, chainId }, data, { upsert: true });
Expand Down
Loading

0 comments on commit bb84099

Please sign in to comment.