diff --git a/common_knowledge/Chain-Events.md b/common_knowledge/Chain-Events.md new file mode 100644 index 00000000000..6a85a425e49 --- /dev/null +++ b/common_knowledge/Chain-Events.md @@ -0,0 +1,19 @@ +# EVM Chain Events + +There are 2 methods of adding chain events sources. Parent contracts are contracts that we (Common) deploy and child +contracts are contracts deployed by users (e.g. Single Contests). Parent contract addresses are stable (almost never +change) and are therefore hardcoded in `chainConfig.ts`. On the other hand, we cannot know the address of a child +contract ahead of time since it is deployed at runtime. For this reason, child contract sources are stored in the +EvmEventSources table with a reference to the parent contract in the EventRegistry. + +These instructions only describe how to ensure events are picked it up by EVM CE _not_ how these events are processed. + +## Adding Parent Contract Events +1. Add the contract address in the `factoryContracts` object in `chainConfig.ts`. +2. Add the relevant event signatures in `eventSignatures.ts`. +3. Update the `EventRegistry` object in `eventRegistry.ts` to reference the new contract/event. + +## Adding Child Contract Events +1. Add the relevant event signatures in `eventSignatures.ts`. +2. Update the `EventRegistry` object in `eventRegistry.ts` to reference the new events. +3. Ensure that the parent events pertaining to child contract launches create sources in the `EvmEventSources` table. \ No newline at end of file diff --git a/libs/evm-protocols/src/common-protocol/chainConfig.ts b/libs/evm-protocols/src/common-protocol/chainConfig.ts index 638e6dc2908..97bd1477e8e 100644 --- a/libs/evm-protocols/src/common-protocol/chainConfig.ts +++ b/libs/evm-protocols/src/common-protocol/chainConfig.ts @@ -32,6 +32,7 @@ type factoryContractsType = { launchpad?: string; lpBondingCurve?: string; tokenCommunityManager?: string; + referralFeeManager?: string; chainId: number; }; }; @@ -50,6 +51,7 @@ export const factoryContracts = { launchpad: '0xc6e7B0AdDf35AE4a5A65bb3bCb78D11Db6c8fB8F', lpBondingCurve: '0x2ECc0af0e4794F0Ab4797549a5a8cf97688D7D21', tokenCommunityManager: '0xC8fe1F23AbC4Eb55f4aa9E52dAFa3761111CF03a', + referralFeeManager: '0xdc07fEaf01666B7f5dED2F59D895543Ed3FAE1cA', chainId: 84532, }, [ValidChains.Blast]: { diff --git a/libs/evm-protocols/src/event-registry/eventRegistry.ts b/libs/evm-protocols/src/event-registry/eventRegistry.ts index 320aaf56539..6a703d3465a 100644 --- a/libs/evm-protocols/src/event-registry/eventRegistry.ts +++ b/libs/evm-protocols/src/event-registry/eventRegistry.ts @@ -28,6 +28,11 @@ type ContractAddresses = { ? 'tokenCommunityManager' extends keyof (typeof factoryContracts)[key] ? (typeof factoryContracts)[key]['tokenCommunityManager'] : never + : never) + | (key extends keyof typeof factoryContracts + ? 'referralFeeManager' extends keyof (typeof factoryContracts)[key] + ? (typeof factoryContracts)[key]['referralFeeManager'] + : never : never); }; @@ -103,6 +108,14 @@ const tokenCommunityManagerSource: ContractSource = { eventSignatures: [], } satisfies ContractSource; +const referralFeeManagerSource: ContractSource = { + abi: tokenCommunityManagerAbi, + eventSignatures: [ + EvmEventSignatures.Referrals.ReferralSet, + EvmEventSignatures.Referrals.ReferralSet, + ], +}; + /** * Note that this object does not contain details for contracts deployed by users * at runtime. Those contracts remain in the EvmEventSources table. @@ -121,6 +134,8 @@ export const EventRegistry = { lpBondingCurveSource, [factoryContracts[ValidChains.SepoliaBase].tokenCommunityManager]: tokenCommunityManagerSource, + [factoryContracts[ValidChains.SepoliaBase].referralFeeManager]: + referralFeeManagerSource, }, [ValidChains.Sepolia]: { [factoryContracts[ValidChains.Sepolia].factory]: namespaceFactorySource, diff --git a/libs/evm-protocols/src/event-registry/eventSignatures.ts b/libs/evm-protocols/src/event-registry/eventSignatures.ts index 0e0d01bcf14..4d23b0410c3 100644 --- a/libs/evm-protocols/src/event-registry/eventSignatures.ts +++ b/libs/evm-protocols/src/event-registry/eventSignatures.ts @@ -44,6 +44,12 @@ export const EvmEventSignatures = { TokenRegistered: '0xc2fe88a1a3c1957424571593960b97f158a519d0aa4cef9e13a247c64f1f4c35', }, + Referrals: { + ReferralSet: + '0xdf63218877cb126f6c003f2b7f77327674cd6a0b53ad51deac392548ec12b0ed', + FeeDistributed: + '0xadecf9f6e10f953395058158f0e6e399835cf1d045bbed7ecfa82947ecc0a368', + }, } as const; type Values = T[keyof T]; diff --git a/libs/model/src/community/CreateCommunity.command.ts b/libs/model/src/community/CreateCommunity.command.ts index 462ccf531ae..f7f806f3f68 100644 --- a/libs/model/src/community/CreateCommunity.command.ts +++ b/libs/model/src/community/CreateCommunity.command.ts @@ -177,7 +177,7 @@ export function CreateCommunity(): Command { event_payload: { community_id: id, user_id: actor.user.id!, - referral_link: payload.referral_link, + referrer_address: payload.referrer_address, created_at: created.created_at!, }, }, diff --git a/libs/model/src/community/JoinCommunity.command.ts b/libs/model/src/community/JoinCommunity.command.ts index bbc940132a7..9f6aa33a1e9 100644 --- a/libs/model/src/community/JoinCommunity.command.ts +++ b/libs/model/src/community/JoinCommunity.command.ts @@ -114,7 +114,7 @@ export function JoinCommunity(): Command { event_payload: { community_id, user_id: actor.user.id!, - referral_link: payload.referral_link, + referrer_address: payload.referrer_address, created_at: created.created_at!, }, }, diff --git a/libs/model/src/models/associations.ts b/libs/model/src/models/associations.ts index 50f8538305d..29db3e8d0ad 100644 --- a/libs/model/src/models/associations.ts +++ b/libs/model/src/models/associations.ts @@ -20,18 +20,6 @@ export const buildAssociations = (db: DB) => { onUpdate: 'CASCADE', onDelete: 'CASCADE', }) - .withMany(db.Referral, { - foreignKey: 'referrer_id', - asOne: 'referrer', - onUpdate: 'CASCADE', - onDelete: 'CASCADE', - }) - .withMany(db.Referral, { - foreignKey: 'referee_id', - asOne: 'referee', - onUpdate: 'CASCADE', - onDelete: 'CASCADE', - }) .withMany(db.XpLog, { foreignKey: 'user_id', onDelete: 'CASCADE', diff --git a/libs/model/src/models/factories.ts b/libs/model/src/models/factories.ts index dc1f1e32713..9bc496668c6 100644 --- a/libs/model/src/models/factories.ts +++ b/libs/model/src/models/factories.ts @@ -30,6 +30,7 @@ import ProfileTags from './profile_tags'; import { Quest, QuestAction, QuestActionMeta } from './quest'; import Reaction from './reaction'; import { Referral } from './referral'; +import { ReferralFee } from './referral_fee'; import SsoToken from './sso_token'; import StakeTransaction from './stake_transaction'; import StarredCommunity from './starred_community'; @@ -78,6 +79,7 @@ export const Factories = { QuestActionMeta, Reaction, Referral, + ReferralFee, SsoToken, StakeTransaction, StarredCommunity, diff --git a/libs/model/src/models/index.ts b/libs/model/src/models/index.ts index 998295baa4e..084e9562c40 100644 --- a/libs/model/src/models/index.ts +++ b/libs/model/src/models/index.ts @@ -60,6 +60,8 @@ export * from './outbox'; export * from './poll'; export * from './profile_tags'; export * from './reaction'; +export * from './referral'; +export * from './referral_fee'; export * from './role'; export * from './role_assignment'; export * from './sso_token'; diff --git a/libs/model/src/models/referral.ts b/libs/model/src/models/referral.ts index d8bdf2fd962..c1aa26bccd1 100644 --- a/libs/model/src/models/referral.ts +++ b/libs/model/src/models/referral.ts @@ -12,29 +12,62 @@ export const Referral = ( sequelize.define( 'Referral', { - referrer_id: { + id: { type: Sequelize.INTEGER, - allowNull: false, primaryKey: true, + autoIncrement: true, }, - referee_id: { + eth_chain_id: { type: Sequelize.INTEGER, + allowNull: true, + }, + transaction_hash: { + type: Sequelize.STRING, + allowNull: true, + }, + namespace_address: { + type: Sequelize.STRING, + allowNull: true, + }, + referee_address: { + type: Sequelize.STRING, allowNull: false, - primaryKey: true, }, - event_name: { + referrer_address: { type: Sequelize.STRING, allowNull: false, - primaryKey: true, }, - event_payload: { type: Sequelize.JSONB, allowNull: false }, - created_at: { type: Sequelize.DATE, allowNull: true, primaryKey: true }, + referrer_received_eth_amount: { + type: Sequelize.FLOAT, + allowNull: false, + defaultValue: 0, + }, + created_on_chain_timestamp: { + type: Sequelize.INTEGER, + allowNull: true, + }, + created_off_chain_at: { + type: Sequelize.DATE, + allowNull: false, + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + }, }, { timestamps: true, - createdAt: 'created_at', - updatedAt: false, + createdAt: 'created_off_chain_at', + updatedAt: 'updated_at', underscored: true, tableName: 'Referrals', + indexes: [ + { fields: ['referee_address'] }, + { fields: ['referrer_address'] }, + { + fields: ['eth_chain_id', 'transaction_hash'], + unique: true, + }, + ], }, ); diff --git a/libs/model/src/models/referral_fee.ts b/libs/model/src/models/referral_fee.ts new file mode 100644 index 00000000000..26e6cd37124 --- /dev/null +++ b/libs/model/src/models/referral_fee.ts @@ -0,0 +1,49 @@ +import * as schemas from '@hicommonwealth/schemas'; +import Sequelize from 'sequelize'; +import { z } from 'zod'; +import type { ModelInstance } from './types'; + +export type ReferralFeesAttributes = z.infer; +export type ReferralFeesInstance = ModelInstance; + +export const ReferralFee = ( + sequelize: Sequelize.Sequelize, +): Sequelize.ModelStatic => + sequelize.define( + 'ReferralFee', + { + eth_chain_id: { + type: Sequelize.INTEGER, + primaryKey: true, + }, + transaction_hash: { + type: Sequelize.STRING, + primaryKey: true, + }, + namespace_address: { + type: Sequelize.STRING, + allowNull: false, + }, + distributed_token_address: { + type: Sequelize.STRING, + allowNull: false, + }, + referrer_recipient_address: { + type: Sequelize.STRING, + allowNull: false, + }, + referrer_received_amount: { + type: Sequelize.FLOAT, + allowNull: false, + }, + transaction_timestamp: { + type: Sequelize.INTEGER, + allowNull: false, + }, + }, + { + timestamps: false, + underscored: true, + tableName: 'ReferralFees', + }, + ); diff --git a/libs/model/src/models/user.ts b/libs/model/src/models/user.ts index 594df293c12..acc1d103c01 100644 --- a/libs/model/src/models/user.ts +++ b/libs/model/src/models/user.ts @@ -74,7 +74,11 @@ export default (sequelize: Sequelize.Sequelize): UserModelStatic => selected_community_id: { type: Sequelize.STRING, allowNull: true }, profile: { type: Sequelize.JSONB, allowNull: false }, xp_points: { type: Sequelize.INTEGER, defaultValue: 0, allowNull: true }, - referral_link: { type: Sequelize.STRING, allowNull: true }, + referral_eth_earnings: { + type: Sequelize.FLOAT, + allowNull: false, + defaultValue: 0, + }, }, { timestamps: true, diff --git a/libs/model/src/policies/chainEventCreatedPolicy.ts b/libs/model/src/policies/chainEventCreatedPolicy.ts index 328ca04eeeb..15dee33cfdb 100644 --- a/libs/model/src/policies/chainEventCreatedPolicy.ts +++ b/libs/model/src/policies/chainEventCreatedPolicy.ts @@ -7,6 +7,8 @@ import { systemActor } from '../middleware'; import { CreateLaunchpadToken } from '../token/CreateToken.command'; import { handleCommunityStakeTrades } from './handleCommunityStakeTrades'; import { handleLaunchpadTrade } from './handleLaunchpadTrade'; +import { handleReferralFeeDistributed } from './handleReferralFeeDistributed'; +import { handleReferralSet } from './handleReferralSet'; const log = logger(import.meta); @@ -45,6 +47,16 @@ export const processChainEventCreated: EventHandler< payload.eventSource.eventSignature === EvmEventSignatures.Launchpad.Trade ) { await handleLaunchpadTrade(payload); + } else if ( + payload.eventSource.eventSignature === + EvmEventSignatures.Referrals.ReferralSet + ) { + await handleReferralSet(payload); + } else if ( + payload.eventSource.eventSignature === + EvmEventSignatures.Referrals.FeeDistributed + ) { + await handleReferralFeeDistributed(payload); } else { log.error('Attempted to process an unsupported chain-event', undefined, { event: payload, diff --git a/libs/model/src/policies/handleCommunityStakeTrades.ts b/libs/model/src/policies/handleCommunityStakeTrades.ts index 4b300993fff..2e654c73374 100644 --- a/libs/model/src/policies/handleCommunityStakeTrades.ts +++ b/libs/model/src/policies/handleCommunityStakeTrades.ts @@ -4,6 +4,7 @@ import { BigNumber } from 'ethers'; import Web3 from 'web3'; import { z } from 'zod'; import { DB } from '../models'; +import { chainNodeMustExist } from './utils'; const log = logger(import.meta); @@ -41,17 +42,7 @@ export async function handleCommunityStakeTrades( return; } - const chainNode = await models.ChainNode.scope('withPrivateData').findOne({ - where: { - eth_chain_id: event.eventSource.ethChainId, - }, - }); - if (!chainNode) { - log.error('ChainNode associated to chain event not found!', undefined, { - event, - }); - return; - } + const chainNode = await chainNodeMustExist(event.eventSource.ethChainId); if (!chainNode.private_url) { log.error('ChainNode is missing a private url', undefined, { diff --git a/libs/model/src/policies/handleLaunchpadTrade.ts b/libs/model/src/policies/handleLaunchpadTrade.ts index dd5bb553748..c2ecdfdc94e 100644 --- a/libs/model/src/policies/handleLaunchpadTrade.ts +++ b/libs/model/src/policies/handleLaunchpadTrade.ts @@ -6,6 +6,7 @@ import Web3 from 'web3'; import { z } from 'zod'; import { models } from '../database'; import { commonProtocol } from '../services'; +import { chainNodeMustExist } from './utils'; const log = logger(import.meta); @@ -32,16 +33,7 @@ export async function handleLaunchpadTrade( throw new Error('Token not found'); } - const chainNode = await models.ChainNode.scope('withPrivateData').findOne({ - where: { - eth_chain_id: event.eventSource.ethChainId, - }, - }); - - if (!chainNode) { - // TODO: throw custom error with no retries -> straight to deadletter - throw new Error('Unsupported chain'); - } + const chainNode = await chainNodeMustExist(event.eventSource.ethChainId); const trade = await models.LaunchpadTrade.findOne({ where: { diff --git a/libs/model/src/policies/handleReferralFeeDistributed.ts b/libs/model/src/policies/handleReferralFeeDistributed.ts new file mode 100644 index 00000000000..9d648c7a843 --- /dev/null +++ b/libs/model/src/policies/handleReferralFeeDistributed.ts @@ -0,0 +1,85 @@ +import { models } from '@hicommonwealth/model'; +import { chainEvents, events } from '@hicommonwealth/schemas'; +import { ZERO_ADDRESS } from '@hicommonwealth/shared'; +import { BigNumber } from 'ethers'; +import Web3 from 'web3'; +import { z } from 'zod'; +import { chainNodeMustExist } from './utils'; + +export async function handleReferralFeeDistributed( + event: z.infer, +) { + const { + 0: namespaceAddress, + 1: tokenAddress, + // 2: totalAmountDistributed, + 3: referrerAddress, + 4: referrerReceivedAmount, + } = event.parsedArgs as z.infer; + + const existingFee = await models.ReferralFee.findOne({ + where: { + eth_chain_id: event.eventSource.ethChainId, + transaction_hash: event.rawLog.transactionHash, + }, + }); + + if (event.rawLog.removed && existingFee) { + await existingFee.destroy(); + return; + } else if (existingFee) return; + + const chainNode = await chainNodeMustExist(event.eventSource.ethChainId); + + const web3 = new Web3(chainNode.private_url! || chainNode.url!); + const block = await web3.eth.getBlock(event.rawLog.blockHash); + + const feeAmount = + Number(BigNumber.from(referrerReceivedAmount).toBigInt()) / 1e18; + + await models.sequelize.transaction(async (transaction) => { + await models.ReferralFee.create( + { + eth_chain_id: event.eventSource.ethChainId, + transaction_hash: event.rawLog.transactionHash, + namespace_address: namespaceAddress, + distributed_token_address: tokenAddress, + referrer_recipient_address: referrerAddress, + referrer_received_amount: feeAmount, + transaction_timestamp: Number(block.timestamp), + }, + { transaction }, + ); + + // if native token i.e. ETH + if (tokenAddress === ZERO_ADDRESS) { + const userAddress = await models.Address.findOne({ + where: { + address: referrerAddress, + }, + transaction, + }); + if (userAddress) { + await models.User.increment('referral_eth_earnings', { + by: feeAmount, + where: { + id: userAddress.user_id!, + }, + transaction, + }); + } + + await models.Referral.increment('referrer_received_eth_amount', { + by: feeAmount, + where: { + referrer_address: referrerAddress, + referee_address: event.rawLog.address, + }, + transaction, + }); + } + }); + + // TODO: on create address update user.referral_eth_earnings by querying referrals + // https://github.com/hicommonwealth/commonwealth/issues/10368 +} diff --git a/libs/model/src/policies/handleReferralSet.ts b/libs/model/src/policies/handleReferralSet.ts new file mode 100644 index 00000000000..ed5e5da220a --- /dev/null +++ b/libs/model/src/policies/handleReferralSet.ts @@ -0,0 +1,69 @@ +import { models } from '@hicommonwealth/model'; +import { chainEvents, events } from '@hicommonwealth/schemas'; +import Web3 from 'web3'; +import { z } from 'zod'; +import { chainNodeMustExist } from './utils'; + +export async function handleReferralSet( + event: z.infer, +) { + const { 0: namespaceAddress, 1: referrerAddress } = + event.parsedArgs as z.infer; + + const existingReferral = await models.Referral.findOne({ + where: { + referee_address: event.rawLog.address, + referrer_address: referrerAddress, + }, + }); + + if ( + existingReferral?.transaction_hash === event.rawLog.transactionHash && + existingReferral?.eth_chain_id === event.eventSource.ethChainId + ) { + // If the txn was removed from the chain due to re-org, convert Referral to incomplete/off-chain only + if (event.rawLog.removed) + await existingReferral.update({ + eth_chain_id: null, + transaction_hash: null, + namespace_address: null, + created_on_chain_timestamp: null, + }); + + // Referral already exists + return; + } + + const chainNode = await chainNodeMustExist(event.eventSource.ethChainId); + + const web3 = new Web3(chainNode.private_url! || chainNode.url!); + const block = await web3.eth.getBlock(event.rawLog.blockHash); + + // Triggered when an incomplete Referral (off-chain only) was created during user sign up + if (existingReferral && existingReferral?.eth_chain_id === null) { + await existingReferral.update({ + eth_chain_id: event.eventSource.ethChainId, + transaction_hash: event.rawLog.transactionHash, + namespace_address: namespaceAddress, + created_on_chain_timestamp: Number(block.timestamp), + }); + } + // Triggered when the referral was set on-chain only (user didn't sign up i.e. no incomplete Referral) + // OR when the on-chain referral is on a new chain + else if ( + !existingReferral || + (existingReferral && + existingReferral.eth_chain_id !== event.eventSource.ethChainId && + existingReferral?.transaction_hash !== event.rawLog.transactionHash) + ) { + await models.Referral.create({ + eth_chain_id: event.eventSource.ethChainId, + transaction_hash: event.rawLog.transactionHash, + namespace_address: namespaceAddress, + referee_address: event.rawLog.address, + referrer_address: referrerAddress, + referrer_received_eth_amount: 0, + created_on_chain_timestamp: Number(block.timestamp), + }); + } +} diff --git a/libs/model/src/policies/utils.ts b/libs/model/src/policies/utils.ts new file mode 100644 index 00000000000..3799fbc06d9 --- /dev/null +++ b/libs/model/src/policies/utils.ts @@ -0,0 +1,20 @@ +import { CustomRetryStrategyError } from '@hicommonwealth/core'; +import { models } from '@hicommonwealth/model'; + +export async function chainNodeMustExist(ethChainId: number) { + const chainNode = await models.ChainNode.scope('withPrivateData').findOne({ + where: { + eth_chain_id: ethChainId, + }, + }); + + if (!chainNode) { + // dead-letter with no retries -- should never happen + throw new CustomRetryStrategyError( + `Chain node with eth_chain_id ${ethChainId} not found!`, + { strategy: 'nack' }, + ); + } + + return chainNode; +} diff --git a/libs/model/src/user/CreateReferralLink.command.ts b/libs/model/src/user/CreateReferralLink.command.ts deleted file mode 100644 index 71053bdae5b..00000000000 --- a/libs/model/src/user/CreateReferralLink.command.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { InvalidInput, type Command } from '@hicommonwealth/core'; -import * as schemas from '@hicommonwealth/schemas'; -import { randomBytes } from 'crypto'; -import { models } from '../database'; -import { mustExist } from '../middleware/guards'; - -export function CreateReferralLink(): Command< - typeof schemas.CreateReferralLink -> { - return { - ...schemas.CreateReferralLink, - auth: [], - secure: true, - body: async ({ actor }) => { - const user = await models.User.findOne({ - where: { id: actor.user.id }, - attributes: ['id', 'referral_link'], - }); - mustExist('User', user); - - if (user.referral_link) - throw new InvalidInput('Referral link already exists'); - - const randomSegment = randomBytes(8).toString('base64url'); - const referral_link = `ref_${user.id}_${randomSegment}`; - - await user.update({ referral_link }); - - return { - referral_link, - }; - }, - }; -} diff --git a/libs/model/src/user/GetReferralLink.query.ts b/libs/model/src/user/GetReferralLink.query.ts deleted file mode 100644 index 885aed312ee..00000000000 --- a/libs/model/src/user/GetReferralLink.query.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { type Query } from '@hicommonwealth/core'; -import * as schemas from '@hicommonwealth/schemas'; -import { models } from '../database'; - -export function GetReferralLink(): Query { - return { - ...schemas.GetReferralLink, - auth: [], - secure: true, - body: async ({ actor }) => { - const user = await models.User.findOne({ - where: { id: actor.user.id }, - attributes: ['referral_link'], - }); - return { - referral_link: user?.referral_link, - }; - }, - }; -} diff --git a/libs/model/src/user/GetUserProfile.query.ts b/libs/model/src/user/GetUserProfile.query.ts index 4a4a27003ef..852d059efeb 100644 --- a/libs/model/src/user/GetUserProfile.query.ts +++ b/libs/model/src/user/GetUserProfile.query.ts @@ -15,7 +15,7 @@ export function GetUserProfile(): Query { const user = await models.User.findOne({ where: { id: user_id }, - attributes: ['profile', 'xp_points', 'referral_link'], + attributes: ['profile', 'xp_points'], }); mustExist('User', user); @@ -105,7 +105,6 @@ export function GetUserProfile(): Query { // ensure Tag is present in typed response tags: profileTags.map((t) => ({ id: t.Tag!.id!, name: t.Tag!.name })), xp_points: user!.xp_points ?? 0, - referral_link: user!.referral_link, }; }, }; diff --git a/libs/model/src/user/GetUserReferrals.query.ts b/libs/model/src/user/GetUserReferrals.query.ts index 8a471aeac19..94d41b6704d 100644 --- a/libs/model/src/user/GetUserReferrals.query.ts +++ b/libs/model/src/user/GetUserReferrals.query.ts @@ -1,5 +1,6 @@ import { type Query } from '@hicommonwealth/core'; import * as schemas from '@hicommonwealth/schemas'; +import { QueryTypes } from 'sequelize'; import { z } from 'zod'; import { models } from '../database'; @@ -13,38 +14,30 @@ export function GetUserReferrals(): Query { const id = actor.user.isAdmin && payload.user_id ? payload.user_id : actor.user.id; - const referrals = await models.Referral.findAll({ - where: { referrer_id: id }, - include: [ - { - model: models.User, - as: 'referrer', - attributes: ['id', 'profile'], + return await models.sequelize.query>( + ` + WITH referrer_addresses AS (SELECT DISTINCT address + FROM "Addresses" + WHERE user_id = :user_id + AND address LIKE '0x%'), + referrals AS (SELECT * + FROM "Referrals" + WHERE referrer_address IN (SELECT * FROM referrer_addresses)), + referee_addresses AS (SELECT DISTINCT A.address, A.user_id + FROM "Addresses" A + JOIN referrals ON referee_address = A.address) + SELECT R.*, U.id as referee_user_id, U.profile as referee_profile + FROM referrals R + JOIN referee_addresses RA ON RA.address = R.referee_address + JOIN "Users" U ON U.id = RA.user_id; + `, + { + type: QueryTypes.SELECT, + raw: true, + replacements: { + user_id: id, }, - { - model: models.User, - as: 'referee', - attributes: ['id', 'profile'], - }, - ], - }); - - // format view - return referrals.map( - (r) => - ({ - referrer: { - id: r.referrer_id, - profile: r.referrer!.profile, - }, - referee: { - id: r.referee_id, - profile: r.referee!.profile, - }, - event_name: r.event_name, - event_payload: r.event_payload, - created_at: r.created_at, - }) as z.infer, + }, ); }, }; diff --git a/libs/model/src/user/UpdateUser.command.ts b/libs/model/src/user/UpdateUser.command.ts index dd878469e81..3e717bfadc3 100644 --- a/libs/model/src/user/UpdateUser.command.ts +++ b/libs/model/src/user/UpdateUser.command.ts @@ -14,7 +14,7 @@ export function UpdateUser(): Command { if (actor.user.id != payload.id) throw new InvalidInput('Invalid user id'); - const { id, profile, tag_ids, referral_link } = payload; + const { id, profile, tag_ids, referrer_address } = payload; const { slug, name, @@ -103,7 +103,7 @@ export function UpdateUser(): Command { const updated_user = rows.at(0); // emit sign-up flow completed event when: - if (updated_user && user_delta.is_welcome_onboard_flow_complete) + if (updated_user && user_delta.is_welcome_onboard_flow_complete) { await emitEvent( models.Outbox, [ @@ -111,14 +111,15 @@ export function UpdateUser(): Command { event_name: schemas.EventNames.SignUpFlowCompleted, event_payload: { user_id: id, - referral_link, created_at: updated_user.created_at!, + referrer_address, + referee_address: actor.address, }, }, ], transaction, ); - + } return updated_user; } else return user; }, diff --git a/libs/model/src/user/UserReferrals.projection.ts b/libs/model/src/user/UserReferrals.projection.ts index 549c57f25d1..c94abf9075d 100644 --- a/libs/model/src/user/UserReferrals.projection.ts +++ b/libs/model/src/user/UserReferrals.projection.ts @@ -1,10 +1,8 @@ import { Projection } from '@hicommonwealth/core'; import { events } from '@hicommonwealth/schemas'; import { models } from '../database'; -import { getReferrerId } from '../utils/referrals'; const inputs = { - CommunityCreated: events.CommunityCreated, SignUpFlowCompleted: events.SignUpFlowCompleted, }; @@ -12,29 +10,13 @@ export function UserReferrals(): Projection { return { inputs, body: { - CommunityCreated: async ({ payload }) => { - const referral_link = payload.referral_link; - const referrer_id = getReferrerId(referral_link); - if (referrer_id) { - await models.Referral.create({ - referrer_id, - referee_id: payload.user_id, - event_name: 'CommunityCreated', - event_payload: payload, - created_at: new Date(), - }); - } - }, SignUpFlowCompleted: async ({ payload }) => { - const referral_link = payload.referral_link; - const referrer_id = getReferrerId(referral_link); - if (referrer_id) { + if (!payload.referrer_address && !payload.referee_address) return; + if (payload.referrer_address && payload.referee_address) { await models.Referral.create({ - referrer_id, - referee_id: payload.user_id, - event_name: 'SignUpFlowCompleted', - event_payload: payload, - created_at: new Date(), + referee_address: payload.referee_address, + referrer_address: payload.referrer_address, + referrer_received_eth_amount: 0, }); } }, diff --git a/libs/model/src/user/Xp.projection.ts b/libs/model/src/user/Xp.projection.ts index 18b31c60a35..12c21651685 100644 --- a/libs/model/src/user/Xp.projection.ts +++ b/libs/model/src/user/Xp.projection.ts @@ -9,7 +9,6 @@ import { Op, Transaction } from 'sequelize'; import { z } from 'zod'; import { models, sequelize } from '../database'; import { mustExist } from '../middleware/guards'; -import { getReferrerId } from '../utils/referrals'; async function getUserId(payload: { address_id: number }) { const address = await models.Address.findOne({ @@ -20,6 +19,18 @@ async function getUserId(payload: { address_id: number }) { return address.user_id!; } +async function getUserIdByAddress(payload: { + referrer_address?: string; +}): Promise { + if (payload.referrer_address) { + const referrer_user = await models.Address.findOne({ + where: { address: payload.referrer_address }, + attributes: ['user_id'], + }); + if (referrer_user) return referrer_user.user_id!; + } +} + /* * Finds all active quest action metas for a given event */ @@ -72,9 +83,13 @@ async function recordXpsForQuest( user_id: number, event_created_at: Date, action_metas: Array | undefined>, - creator_user_id?: number, + creator_address?: string, ) { await sequelize.transaction(async (transaction) => { + const creator_user_id = await getUserIdByAddress({ + referrer_address: creator_address, + }); + for (const action_meta of action_metas) { if (!action_meta) continue; // get logged actions for this user and action meta @@ -155,10 +170,14 @@ async function recordXpsForEvent( event_name: keyof typeof schemas.QuestEvents, event_created_at: Date, reward_amount: number, - creator_user_id?: number, // referrer user id + creator_address?: string, // referrer address creator_reward_weight?: number, // referrer reward weight ) { await sequelize.transaction(async (transaction) => { + const creator_user_id = await getUserIdByAddress({ + referrer_address: creator_address, + }); + // get logged actions for this user and event const log = await models.XpLog.findAll({ where: { user_id, event_name }, @@ -205,13 +224,12 @@ export function Xp(): Projection { const reward_amount = 20; const creator_reward_weight = 0.2; - const referrer_id = getReferrerId(payload.referral_link); await recordXpsForEvent( payload.user_id, 'SignUpFlowCompleted', payload.created_at!, reward_amount, - referrer_id, + payload.referrer_address, creator_reward_weight, ); }, @@ -221,12 +239,11 @@ export function Xp(): Projection { 'CommunityCreated', ); if (action_metas.length > 0) { - const referrer_id = getReferrerId(payload.referral_link); await recordXpsForQuest( payload.user_id, payload.created_at!, action_metas, - referrer_id, + payload.referrer_address, ); } }, @@ -236,12 +253,11 @@ export function Xp(): Projection { 'CommunityJoined', ); if (action_metas.length > 0) { - const referrer_id = getReferrerId(payload.referral_link); await recordXpsForQuest( payload.user_id, payload.created_at!, action_metas, - referrer_id, + payload.referrer_address, ); } }, @@ -282,7 +298,7 @@ export function Xp(): Projection { { model: models.Address, as: 'Address', - attributes: ['user_id'], + attributes: ['address'], required: true, }, ], @@ -298,7 +314,7 @@ export function Xp(): Projection { user_id, payload.created_at!, action_metas, - comment!.Address!.user_id!, + comment!.Address!.address, ); }, UserMentioned: async () => { diff --git a/libs/model/src/user/index.ts b/libs/model/src/user/index.ts index 440e9eb6a5a..8e8d3fbbca1 100644 --- a/libs/model/src/user/index.ts +++ b/libs/model/src/user/index.ts @@ -1,9 +1,7 @@ export * from './CreateApiKey.command'; -export * from './CreateReferralLink.command'; export * from './DeleteApiKey.command'; export * from './GetApiKey.query'; export * from './GetNewContent.query'; -export * from './GetReferralLink.query'; export * from './GetUserAddresses.query'; export * from './GetUserProfile.query'; export * from './GetUserReferrals.query'; diff --git a/libs/model/src/utils/referrals.ts b/libs/model/src/utils/referrals.ts deleted file mode 100644 index ba83553594d..00000000000 --- a/libs/model/src/utils/referrals.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function getReferrerId(referral_link?: string | null) { - return referral_link?.startsWith('ref_') - ? parseInt(referral_link.split('_').at(1)!) - : undefined; -} diff --git a/libs/model/test/referral/referral-lifecycle.spec.ts b/libs/model/test/referral/referral-lifecycle.spec.ts index 543ac04e2b9..11c2a3c3286 100644 --- a/libs/model/test/referral/referral-lifecycle.spec.ts +++ b/libs/model/test/referral/referral-lifecycle.spec.ts @@ -1,113 +1,66 @@ import { Actor, command, dispose, query } from '@hicommonwealth/core'; -import { ChainNode } from '@hicommonwealth/schemas'; -import { ChainBase, ChainType } from '@hicommonwealth/shared'; import { GetUserReferrals } from 'model/src/user/GetUserReferrals.query'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { z } from 'zod'; -import { CreateCommunity } from '../../src/community'; -import { - CreateReferralLink, - GetReferralLink, - UpdateUser, - UserReferrals, -} from '../../src/user'; +import { seed } from '../../src/tester'; +import { UpdateUser, UserReferrals } from '../../src/user'; import { drainOutbox, seedCommunity } from '../utils'; describe('Referral lifecycle', () => { let admin: Actor; let member: Actor; - let node: z.infer; + let nonMember: Actor; beforeAll(async () => { - const { node: _node, actors } = await seedCommunity({ + const { actors, base } = await seedCommunity({ roles: ['admin', 'member'], }); admin = actors.admin; member = actors.member; - node = _node!; - }); - - afterAll(async () => { - await dispose()(); - }); - - it('should create a referral when creating a community with a referral link', async () => { - // admin creates a referral link - const response = await command(CreateReferralLink(), { - actor: admin, - payload: {}, - }); - - // member creates a community using the referral link - const id = 'test-community-with-referral-link'; - await command(CreateCommunity(), { - actor: member, - payload: { - chain_node_id: node.id!, - id, - name: id, - type: ChainType.Offchain, - base: ChainBase.Ethereum, - default_symbol: 'TEST', - social_links: [], - directory_page_enabled: false, - tags: [], - referral_link: response?.referral_link, + const [nonMemberUser] = await seed('User', { + profile: { + name: 'non-member', }, + isAdmin: false, + is_welcome_onboard_flow_complete: false, }); - - await drainOutbox(['CommunityCreated'], UserReferrals); - - // get referrals - const referrals = await query(GetUserReferrals(), { - actor: admin, - payload: {}, + const [nonMemberAddress] = await seed('Address', { + community_id: base!.id!, + user_id: nonMemberUser!.id!, }); - - expect(referrals?.length).toBe(1); - - const ref = referrals!.at(0)!; - expect(ref).toMatchObject({ - referrer: { - id: admin.user.id, - profile: { - name: 'admin', - avatar_url: ref.referrer.profile.avatar_url, - }, + nonMember = { + user: { + id: nonMemberUser!.id!, + email: nonMemberUser!.email!, + isAdmin: false, }, - referee: { - id: member.user.id, - profile: { - name: 'member', - avatar_url: ref.referee.profile.avatar_url, - }, - }, - event_name: 'CommunityCreated', - event_payload: { - community_id: id, - user_id: member.user.id, - created_at: ref.event_payload.created_at, - referral_link: response!.referral_link, - }, - }); + address: nonMemberAddress!.address!, + }; }); - it('should create a referral when signing up with a referral link', async () => { - const response = await query(GetReferralLink(), { - actor: admin, - payload: {}, - }); + afterAll(async () => { + await dispose()(); + }); + it('should create a referral when signing up with a referral link', async () => { // member signs up with the referral link await command(UpdateUser(), { actor: member, payload: { id: member.user.id!, - referral_link: response?.referral_link, + referrer_address: admin.address, profile: { name: 'member' }, // this flags is_welcome_onboard_flow_complete }, }); + await command(UpdateUser(), { + actor: nonMember, + payload: { + id: nonMember.user.id!, + referrer_address: admin.address, + profile: { name: 'non-member' }, // this flags is_welcome_onboard_flow_complete + }, + }); + await drainOutbox(['SignUpFlowCompleted'], UserReferrals); // get referrals @@ -118,27 +71,33 @@ describe('Referral lifecycle', () => { expect(referrals?.length).toBe(2); - const ref = referrals!.at(1)!; + let ref = referrals!.at(0)!; expect(ref).toMatchObject({ - referrer: { - id: admin.user.id, - profile: { - name: 'admin', - avatar_url: ref.referrer.profile.avatar_url, - }, - }, - referee: { - id: member.user.id, - profile: { - name: 'member', - avatar_url: ref.referee.profile.avatar_url, - }, - }, - event_name: 'SignUpFlowCompleted', - event_payload: { - user_id: member.user.id, - referral_link: response?.referral_link, - }, + eth_chain_id: null, + transaction_hash: null, + namespace_address: null, + referee_address: member.address, + referrer_address: admin.address, + referrer_received_eth_amount: 0, + created_on_chain_timestamp: null, + created_off_chain_at: expect.any(Date), + updated_at: expect.any(Date), + referee_user_id: member.user.id, + referee_profile: { name: 'member' }, + }); + ref = referrals!.at(1)!; + expect(ref).toMatchObject({ + eth_chain_id: null, + transaction_hash: null, + namespace_address: null, + referee_address: nonMember.address, + referrer_address: admin.address, + referrer_received_eth_amount: 0, + created_on_chain_timestamp: null, + created_off_chain_at: expect.any(Date), + updated_at: expect.any(Date), + referee_user_id: nonMember.user.id!, + referee_profile: { name: 'non-member' }, }); }); }); diff --git a/libs/model/test/user/user-lifecycle.spec.ts b/libs/model/test/user/user-lifecycle.spec.ts index a3910d006f2..389149d0dba 100644 --- a/libs/model/test/user/user-lifecycle.spec.ts +++ b/libs/model/test/user/user-lifecycle.spec.ts @@ -11,14 +11,7 @@ import { CreateComment, CreateCommentReaction } from '../../src/comment'; import { models } from '../../src/database'; import { CreateQuest, UpdateQuest } from '../../src/quest'; import { CreateThread } from '../../src/thread'; -import { - CreateReferralLink, - GetReferralLink, - GetUserProfile, - GetXps, - UpdateUser, - Xp, -} from '../../src/user'; +import { GetUserProfile, GetXps, UpdateUser, Xp } from '../../src/user'; import { drainOutbox } from '../utils'; import { seedCommunity } from '../utils/community-seeder'; @@ -45,32 +38,6 @@ describe('User lifecycle', () => { await dispose()(); }); - describe('referrals', () => { - it('should create referral link when user is created', async () => { - const response = await command(CreateReferralLink(), { - actor: member, - payload: {}, - }); - expect(response!.referral_link).toBeDefined(); - - // make sure it's saved - const response2 = await query(GetReferralLink(), { - actor: member, - payload: {}, - }); - expect(response2!.referral_link).to.eq(response?.referral_link); - }); - - it('should fail to create referral link when one already exists', async () => { - expect( - command(CreateReferralLink(), { - actor: member, - payload: {}, - }), - ).rejects.toThrowError('Referral link already exists'); - }); - }); - describe('xp', () => { it('should project xp points', async () => { // setup quest @@ -310,11 +277,6 @@ describe('User lifecycle', () => { }, }); - const referral_response = await query(GetReferralLink(), { - actor: member, - payload: {}, - }); - // TODO: command to create a new user const new_user = await models.User.create({ profile: { @@ -348,7 +310,7 @@ describe('User lifecycle', () => { actor: new_actor, payload: { id: new_user.id!, - referral_link: referral_response?.referral_link, + referrer_address: member.address!, profile: { name: 'New User Updated', }, @@ -360,7 +322,7 @@ describe('User lifecycle', () => { actor: new_actor, payload: { community_id, - referral_link: referral_response?.referral_link, + referrer_address: member.address!, }, }); @@ -396,7 +358,7 @@ describe('User lifecycle', () => { // - 28 from the first test // - 28 from the second test // - 10 from the referral when new user joined the community - // - 4 from the referral on a sign up flow completed + // - 4 from the referral on a sign-up flow completed expect(member_profile?.xp_points).to.equal(28 + 28 + 10 + 4); // expect xp points awarded to user joining the community diff --git a/libs/schemas/src/commands/community.schemas.ts b/libs/schemas/src/commands/community.schemas.ts index 151e73ff032..66dce805e5e 100644 --- a/libs/schemas/src/commands/community.schemas.ts +++ b/libs/schemas/src/commands/community.schemas.ts @@ -48,7 +48,7 @@ export const CreateCommunity = { // hidden optional params token_name: z.string().optional(), - referral_link: z.string().optional(), + referrer_address: z.string().optional(), // deprecated params to be removed default_symbol: z.string().max(9), @@ -310,7 +310,7 @@ export const RefreshCommunityMemberships = { export const JoinCommunity = { input: z.object({ community_id: z.string(), - referral_link: z.string().nullish(), + referrer_address: z.string().optional(), }), output: z.object({ community_id: z.string(), diff --git a/libs/schemas/src/commands/user.schemas.ts b/libs/schemas/src/commands/user.schemas.ts index 72a42546f15..9f8a6b7592e 100644 --- a/libs/schemas/src/commands/user.schemas.ts +++ b/libs/schemas/src/commands/user.schemas.ts @@ -6,7 +6,7 @@ export const UpdateUser = { id: z.number(), promotional_emails_enabled: z.boolean().nullish(), tag_ids: z.number().array().nullish(), - referral_link: z.string().nullish(), + referrer_address: z.string().optional(), }), output: User, }; @@ -39,17 +39,3 @@ export const DeleteApiKey = { deleted: z.boolean(), }), }; - -export const CreateReferralLink = { - input: z.object({}), - output: z.object({ - referral_link: z.string(), - }), -}; - -export const GetReferralLink = { - input: z.object({}), - output: z.object({ - referral_link: z.string().nullish(), - }), -}; diff --git a/libs/schemas/src/entities/referral.schemas.ts b/libs/schemas/src/entities/referral.schemas.ts index 76299036a3b..6fc7eb6d90c 100644 --- a/libs/schemas/src/entities/referral.schemas.ts +++ b/libs/schemas/src/entities/referral.schemas.ts @@ -1,6 +1,5 @@ import z from 'zod'; -import { PG_INT } from '../utils'; -import { UserProfile } from './user.schemas'; +import { EVM_ADDRESS, PG_INT } from '../utils'; export const REFERRAL_EVENTS = [ 'CommunityCreated', @@ -9,25 +8,61 @@ export const REFERRAL_EVENTS = [ export const Referral = z .object({ - referrer_id: PG_INT.describe('The user who referred'), - referee_id: PG_INT.describe('The user who was referred'), - event_name: z.enum(REFERRAL_EVENTS).describe('The name of the event'), - event_payload: z.any().describe('The payload of the event'), - created_at: z.coerce.date().optional(), - // TODO: add other metrics - - // associations - referrer: z - .object({ - id: PG_INT, - profile: UserProfile, - }) - .optional(), - referee: z - .object({ - id: PG_INT, - profile: UserProfile, - }) - .optional(), + id: PG_INT.optional(), + eth_chain_id: PG_INT.nullish().describe( + 'The ID of the EVM chain on which the referral exists', + ), + transaction_hash: z + .string() + .nullish() + .describe('The hash of the transaction in which the referral is created'), + namespace_address: EVM_ADDRESS.nullish().describe( + 'The address of the namespace the referee created with the referral', + ), + referrer_address: EVM_ADDRESS.describe( + 'The address of the user who referred', + ), + referee_address: EVM_ADDRESS.describe( + 'The address of the user who was referred', + ), + referrer_received_eth_amount: z + .number() + .describe( + 'The amount of ETH received by the referrer from fees generated by the referee', + ), + created_on_chain_timestamp: z + .number() + .nullish() + .describe('The timestamp at which the referral was created on chain'), + created_off_chain_at: z.coerce + .date() + .optional() + .describe('The date at which the referral was created off chain'), + updated_at: z.coerce + .date() + .optional() + .describe( + 'The date at which the referrer received eth amount was last updated', + ), }) - .describe('Projects referral events'); + .describe( + 'Projects ReferralSet events and aggregates fees generated by each referee', + ); + +export const ReferralFees = z.object({ + eth_chain_id: PG_INT.describe('The ID of the EVM chain'), + transaction_hash: z.string().describe('The hash of the transaction'), + namespace_address: z.string().describe('The address of the namespace'), + distributed_token_address: z + .string() + .describe('The address of the distributed token'), + referrer_recipient_address: z + .string() + .describe('The address of the referrer recipient'), + referrer_received_amount: z + .number() + .describe('The amount of ETH received by the referrer'), + transaction_timestamp: z + .number() + .describe('The timestamp when the referral fee was distributed'), +}); diff --git a/libs/schemas/src/entities/user.schemas.ts b/libs/schemas/src/entities/user.schemas.ts index 3d26d60d951..c9fe1582230 100644 --- a/libs/schemas/src/entities/user.schemas.ts +++ b/libs/schemas/src/entities/user.schemas.ts @@ -53,7 +53,7 @@ export const User = z.object({ profile: UserProfile, xp_points: PG_INT.default(0).nullish(), - referral_link: z.string().nullish(), + referral_eth_earnings: z.number().optional(), ProfileTags: z.array(ProfileTags).optional(), ApiKey: ApiKey.optional(), diff --git a/libs/schemas/src/events/chain-event.schemas.ts b/libs/schemas/src/events/chain-event.schemas.ts index 53918bebb05..14fe4e20d7d 100644 --- a/libs/schemas/src/events/chain-event.schemas.ts +++ b/libs/schemas/src/events/chain-event.schemas.ts @@ -1,4 +1,5 @@ -// TODO: temporary - will be deleted as part of chain-events removal +// TODO: will be removed when finalizing transition to raw ChainEventCreated schema +// and consumer side ABI parsing with Viem/abi-types import { z } from 'zod'; import { ETHERS_BIG_NUMBER, EVM_ADDRESS, zBoolean } from '../utils'; @@ -34,3 +35,20 @@ export const LaunchpadTrade = z.tuple([ ETHERS_BIG_NUMBER.describe('protocolEthAmount'), ETHERS_BIG_NUMBER.describe('supply'), ]); + +export const ReferralSet = z.tuple([ + EVM_ADDRESS.describe('namespace address'), + EVM_ADDRESS.describe('referer address'), +]); + +export const ReferralFeeDistributed = z.tuple([ + EVM_ADDRESS.describe('namespace address'), + EVM_ADDRESS.describe('distributed token address'), + ETHERS_BIG_NUMBER.describe( + 'total amount of the token that is distributed (includes protocol fee, referral fee, etc)', + ), + EVM_ADDRESS.describe("the referrer's address"), + ETHERS_BIG_NUMBER.describe( + 'the amount of the token that is distributed to the referrer', + ), +]); diff --git a/libs/schemas/src/events/events.schemas.ts b/libs/schemas/src/events/events.schemas.ts index 9fc502b0997..db47db28791 100644 --- a/libs/schemas/src/events/events.schemas.ts +++ b/libs/schemas/src/events/events.schemas.ts @@ -1,3 +1,4 @@ +import { EvmEventSignatures } from '@hicommonwealth/evm-protocols'; import { z } from 'zod'; import { FarcasterCast } from '../commands/contest.schemas'; import { Comment } from '../entities/comment.schemas'; @@ -11,6 +12,8 @@ import { LaunchpadTokenCreated, LaunchpadTrade, NamespaceDeployed, + ReferralFeeDistributed, + ReferralSet, } from './chain-event.schemas'; import { EventMetadata } from './util.schemas'; @@ -61,14 +64,14 @@ export const UserMentioned = z.object({ export const CommunityCreated = z.object({ community_id: z.string(), user_id: z.number(), - referral_link: z.string().optional(), + referrer_address: z.string().optional(), created_at: z.coerce.date(), }); export const CommunityJoined = z.object({ community_id: z.string(), user_id: z.number(), - referral_link: z.string().nullish(), + referrer_address: z.string().optional(), created_at: z.coerce.date(), }); @@ -179,35 +182,41 @@ export const ChainEventCreated = z.union([ ChainEventCreatedBase.extend({ eventSource: ChainEventCreatedBase.shape.eventSource.extend({ eventSignature: z.literal( - '0x8870ba2202802ce285ce6bead5ac915b6dc2d35c8a9d6f96fa56de9de12829d5', + EvmEventSignatures.NamespaceFactory.NamespaceDeployed, ), }), parsedArgs: NamespaceDeployed, }), ChainEventCreatedBase.extend({ eventSource: ChainEventCreatedBase.shape.eventSource.extend({ - eventSignature: z.literal( - '0xfc13c9a8a9a619ac78b803aecb26abdd009182411d51a986090f82519d88a89e', - ), + eventSignature: z.literal(EvmEventSignatures.CommunityStake.Trade), }), parsedArgs: CommunityStakeTrade, }), ChainEventCreatedBase.extend({ eventSource: ChainEventCreatedBase.shape.eventSource.extend({ - eventSignature: z.literal( - '0xd7ca5dc2f8c6bb37c3a4de2a81499b25f8ca8bbb3082010244fe747077d0f6cc', - ), + eventSignature: z.literal(EvmEventSignatures.Launchpad.TokenLaunched), }), parsedArgs: LaunchpadTokenCreated, }), ChainEventCreatedBase.extend({ eventSource: ChainEventCreatedBase.shape.eventSource.extend({ - eventSignature: z.literal( - '0x9adcf0ad0cda63c4d50f26a48925cf6405df27d422a39c456b5f03f661c82982', - ), + eventSignature: z.literal(EvmEventSignatures.Launchpad.Trade), }), parsedArgs: LaunchpadTrade, }), + ChainEventCreatedBase.extend({ + eventSource: ChainEventCreatedBase.shape.eventSource.extend({ + eventSignature: z.literal(EvmEventSignatures.Referrals.ReferralSet), + }), + parsedArgs: ReferralSet, + }), + ChainEventCreatedBase.extend({ + eventSource: ChainEventCreatedBase.shape.eventSource.extend({ + eventSignature: z.literal(EvmEventSignatures.Referrals.FeeDistributed), + }), + parsedArgs: ReferralFeeDistributed, + }), ]); // on-chain contest manager events @@ -280,7 +289,8 @@ export const FarcasterVoteCreated = FarcasterAction.extend({ export const SignUpFlowCompleted = z.object({ user_id: z.number(), created_at: z.coerce.date(), - referral_link: z.string().nullish(), + referrer_address: z.string().optional(), + referee_address: z.string().optional(), }); export const ContestRolloverTimerTicked = z.object({}); diff --git a/libs/schemas/src/queries/user.schemas.ts b/libs/schemas/src/queries/user.schemas.ts index 79496dc7a7d..1f54070816d 100644 --- a/libs/schemas/src/queries/user.schemas.ts +++ b/libs/schemas/src/queries/user.schemas.ts @@ -31,7 +31,6 @@ export const UserProfileView = z.object({ isOwner: z.boolean(), tags: z.array(Tags.extend({ id: PG_INT })), xp_points: z.number().int(), - referral_link: z.string().nullish(), }); export const GetUserProfile = { @@ -86,16 +85,12 @@ export const GetUserAddresses = { ), }; -export const ReferralView = Referral.extend({ - referrer: z.object({ - id: PG_INT, - profile: UserProfile, +export const ReferralView = z.array( + Referral.extend({ + referee_user_id: PG_INT, + referee_profile: UserProfile, }), - referee: z.object({ - id: PG_INT, - profile: UserProfile, - }), -}); +); export const GetUserReferrals = { input: z.object({ user_id: PG_INT.optional() }), diff --git a/packages/commonwealth/client/scripts/state/api/user/createReferralLink.ts b/packages/commonwealth/client/scripts/state/api/user/createReferralLink.ts deleted file mode 100644 index 571436aed2e..00000000000 --- a/packages/commonwealth/client/scripts/state/api/user/createReferralLink.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { trpc } from 'utils/trpcClient'; - -export const useCreateReferralLinkMutation = () => { - const utils = trpc.useUtils(); - - return trpc.user.createReferralLink.useMutation({ - onSuccess: async () => { - await utils.user.getReferralLink.invalidate(); - }, - }); -}; diff --git a/packages/commonwealth/client/scripts/state/api/user/getReferralLink.ts b/packages/commonwealth/client/scripts/state/api/user/getReferralLink.ts deleted file mode 100644 index 3eab91ff227..00000000000 --- a/packages/commonwealth/client/scripts/state/api/user/getReferralLink.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useFlag } from 'hooks/useFlag'; -import { trpc } from 'utils/trpcClient'; -import useUserStore from '../../ui/user'; - -export const useGetReferralLinkQuery = () => { - const user = useUserStore(); - const referralsEnabled = useFlag('referrals'); - - return trpc.user.getReferralLink.useQuery( - {}, - { - enabled: user?.isLoggedIn && referralsEnabled, - staleTime: Infinity, - cacheTime: Infinity, - }, - ); -}; diff --git a/packages/commonwealth/client/scripts/state/api/user/index.ts b/packages/commonwealth/client/scripts/state/api/user/index.ts index 6ea19a59418..756589986e8 100644 --- a/packages/commonwealth/client/scripts/state/api/user/index.ts +++ b/packages/commonwealth/client/scripts/state/api/user/index.ts @@ -1,9 +1,7 @@ import { useCreateApiKeyMutation } from './createApiKey'; -import { useCreateReferralLinkMutation } from './createReferralLink'; import { useDeleteApiKeyMutation } from './deleteApiKey'; import { useGetApiKeyQuery } from './getApiKey'; import useGetNewContent from './getNewContent'; -import { useGetReferralLinkQuery } from './getReferralLink'; import useUpdateUserActiveCommunityMutation from './updateActiveCommunity'; import useUpdateUserEmailMutation from './updateEmail'; import useUpdateUserEmailSettingsMutation from './updateEmailSettings'; @@ -11,11 +9,9 @@ import useUpdateUserMutation from './updateUser'; export { useCreateApiKeyMutation, - useCreateReferralLinkMutation, useDeleteApiKeyMutation, useGetApiKeyQuery, useGetNewContent, - useGetReferralLinkQuery, useUpdateUserActiveCommunityMutation, useUpdateUserEmailMutation, useUpdateUserEmailSettingsMutation, diff --git a/packages/commonwealth/client/scripts/views/components/SharePopover/SharePopover.tsx b/packages/commonwealth/client/scripts/views/components/SharePopover/SharePopover.tsx index 2f95e7e6d65..c73f8934400 100644 --- a/packages/commonwealth/client/scripts/views/components/SharePopover/SharePopover.tsx +++ b/packages/commonwealth/client/scripts/views/components/SharePopover/SharePopover.tsx @@ -1,10 +1,10 @@ import { useFlag } from 'hooks/useFlag'; import React from 'react'; +import useUserStore from 'state/ui/user'; import { saveToClipboard } from 'utils/clipboard'; import { PopoverMenu } from 'views/components/component_kit/CWPopoverMenu'; import { PopoverTriggerProps } from 'views/components/component_kit/new_designs/CWPopover'; import { CWThreadAction } from 'views/components/component_kit/new_designs/cw_thread_action'; -import useReferralLink from '../../modals/InviteLinkModal/useReferralLink'; const TWITTER_SHARE_LINK_PREFIX = 'https://twitter.com/intent/tweet?text='; @@ -18,10 +18,9 @@ export const SharePopover = ({ linkToShare, buttonLabel, }: SharePopoverProps) => { + const user = useUserStore(); const referralsEnabled = useFlag('referrals'); - const { getReferralLink } = useReferralLink(); - const defaultRenderTrigger = ( onClick: (e: React.MouseEvent) => void, ) => ( @@ -38,9 +37,10 @@ export const SharePopover = ({ const handleCopy = async () => { if (referralsEnabled) { - const referralLink = await getReferralLink(); const refLink = - linkToShare + (referralLink ? `?refcode=${referralLink}` : ''); + // TODO: @Marcin to check address access (referral link creation) + related changes in this file + linkToShare + + (user.activeAccount ? `?refcode=${user.activeAccount.address}` : ''); await saveToClipboard(refLink, true); } else { await saveToClipboard(linkToShare, true); diff --git a/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/InviteLinkModal.tsx b/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/InviteLinkModal.tsx index f9d92acfc7d..c06f7034f83 100644 --- a/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/InviteLinkModal.tsx +++ b/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/InviteLinkModal.tsx @@ -9,14 +9,12 @@ import { CWModalHeader, } from '../../components/component_kit/new_designs/CWModal'; import { CWTextInput } from '../../components/component_kit/new_designs/CWTextInput'; -import { ShareSkeleton } from './ShareSkeleton'; import { getShareOptions } from './utils'; import app from 'state'; import useUserStore from 'state/ui/user'; import './InviteLinkModal.scss'; -import useReferralLink from './useReferralLink'; interface InviteLinkModalProps { onModalClose: () => void; @@ -27,19 +25,15 @@ const InviteLinkModal = ({ onModalClose }: InviteLinkModalProps) => { const hasJoinedCommunity = !!user.activeAccount; const communityId = hasJoinedCommunity ? app.activeChainId() : ''; - const { referralLink, isLoadingReferralLink, isLoadingCreateReferralLink } = - useReferralLink({ autorun: true }); - const currentUrl = window.location.origin; - const inviteLink = referralLink - ? `${currentUrl}${communityId ? `/${communityId}/discussions` : '/dashboard'}?refcode=${referralLink}` - : ''; + // TODO: @Marcin to check address access (referral link creation) + related changes in this file + const inviteLink = `${currentUrl}${ + communityId ? `/${communityId}/discussions` : '/dashboard' + }?refcode=${user.activeAccount?.address}`; const handleCopy = () => { - if (referralLink) { - saveToClipboard(inviteLink, true).catch(console.error); - } + saveToClipboard(inviteLink, true).catch(console.error); }; const shareOptions = getShareOptions(!!communityId, inviteLink); @@ -60,41 +54,32 @@ const InviteLinkModal = ({ onModalClose }: InviteLinkModalProps) => { : `When you refer your friends to Common, you'll get a portion of any fees they pay to Common over their lifetime engaging with web 3 native forums.`} + <> + } + /> - {isLoadingReferralLink || isLoadingCreateReferralLink ? ( - - ) : ( - <> - } - /> - -
- Share to -
- {shareOptions.map((option) => ( -
- {option.name} - {option.name} -
- ))} -
+
+ Share to +
+ {shareOptions.map((option) => ( +
+ {option.name} + {option.name} +
+ ))}
- - )} +
+
diff --git a/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/useReferralLink.ts b/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/useReferralLink.ts deleted file mode 100644 index 1cbbc5f84bd..00000000000 --- a/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/useReferralLink.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useFlag } from 'hooks/useFlag'; -import useRunOnceOnCondition from 'hooks/useRunOnceOnCondition'; -import { - useCreateReferralLinkMutation, - useGetReferralLinkQuery, -} from 'state/api/user'; - -const useReferralLink = ({ autorun = false }: { autorun?: boolean } = {}) => { - const referralsEnabled = useFlag('referrals'); - const { data: referralLinkData, isLoading: isLoadingReferralLink } = - useGetReferralLinkQuery(); - - const { - mutate: createReferralLink, - mutateAsync: createReferralLinkAsync, - isLoading: isLoadingCreateReferralLink, - } = useCreateReferralLinkMutation(); - - const referralLink = referralLinkData?.referral_link; - - useRunOnceOnCondition({ - callback: () => createReferralLink({}), - shouldRun: - autorun && referralsEnabled && !isLoadingReferralLink && !referralLink, - }); - - const getReferralLink = async () => { - if (referralLink) { - return referralLink; - } - - if (!isLoadingReferralLink && referralsEnabled) { - const result = await createReferralLinkAsync({}); - return result.referral_link; - } - }; - - return { - referralLink, - getReferralLink, - isLoadingReferralLink, - isLoadingCreateReferralLink, - }; -}; - -export default useReferralLink; diff --git a/packages/commonwealth/server/api/user.ts b/packages/commonwealth/server/api/user.ts index 902b812aa12..ce411b060c7 100644 --- a/packages/commonwealth/server/api/user.ts +++ b/packages/commonwealth/server/api/user.ts @@ -10,8 +10,6 @@ export const trpcRouter = trpc.router({ getUserProfile: trpc.query(User.GetUserProfile, trpc.Tag.User), getUserAddresses: trpc.query(User.GetUserAddresses, trpc.Tag.User), searchUserProfiles: trpc.query(User.SearchUserProfiles, trpc.Tag.User), - createReferralLink: trpc.command(User.CreateReferralLink, trpc.Tag.User), - getReferralLink: trpc.query(User.GetReferralLink, trpc.Tag.User), getUserReferrals: trpc.query(User.GetUserReferrals, trpc.Tag.User), getXps: trpc.query(User.GetXps, trpc.Tag.User), }); diff --git a/packages/commonwealth/server/migrations/20241217181509-drop-referrals-table.js b/packages/commonwealth/server/migrations/20241217181509-drop-referrals-table.js new file mode 100644 index 00000000000..4c6fc2420fc --- /dev/null +++ b/packages/commonwealth/server/migrations/20241217181509-drop-referrals-table.js @@ -0,0 +1,25 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.dropTable('Referrals'); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.sequelize.query( + ` + CREATE TABLE "Referrals" ( + "referrer_id" INTEGER NOT NULL, + "referee_id" INTEGER NOT NULL, + "event_name" VARCHAR(255) NOT NULL, + "event_payload" JSONB NOT NULL, + "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY ("referrer_id") REFERENCES "Users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY ("referee_id") REFERENCES "Users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY ("referrer_id", "referee_id", "event_name", "created_at") + ); + `, + ); + }, +}; diff --git a/packages/commonwealth/server/migrations/20241217181510-update-referrals.js b/packages/commonwealth/server/migrations/20241217181510-update-referrals.js new file mode 100644 index 00000000000..3e934844937 --- /dev/null +++ b/packages/commonwealth/server/migrations/20241217181510-update-referrals.js @@ -0,0 +1,135 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.createTable( + 'ReferralFees', + { + eth_chain_id: { + type: Sequelize.INTEGER, + primaryKey: true, + }, + transaction_hash: { + type: Sequelize.STRING, + primaryKey: true, + }, + namespace_address: { + type: Sequelize.STRING, + allowNull: false, + }, + distributed_token_address: { + type: Sequelize.STRING, + allowNull: false, + }, + referrer_recipient_address: { + type: Sequelize.STRING, + allowNull: false, + }, + referrer_received_amount: { + type: Sequelize.FLOAT, + allowNull: false, + }, + transaction_timestamp: { + type: Sequelize.INTEGER, + allowNull: false, + }, + }, + { transaction }, + ); + + await queryInterface.removeColumn('Users', 'referral_link', { + transaction, + }); + await queryInterface.addColumn( + 'Users', + 'referral_eth_earnings', + { + type: Sequelize.FLOAT, + allowNull: false, + defaultValue: 0, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'Referrals', + { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + eth_chain_id: { + type: Sequelize.INTEGER, + allowNull: true, + }, + transaction_hash: { + type: Sequelize.STRING, + allowNull: true, + }, + namespace_address: { + type: Sequelize.STRING, + allowNull: true, + }, + referee_address: { + type: Sequelize.STRING, + allowNull: false, + }, + referrer_address: { + type: Sequelize.STRING, + allowNull: false, + }, + referrer_received_eth_amount: { + type: Sequelize.FLOAT, + allowNull: false, + defaultValue: 0, + }, + created_on_chain_timestamp: { + type: Sequelize.INTEGER, + allowNull: true, + }, + created_off_chain_at: { + type: Sequelize.DATE, + allowNull: false, + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + }, + }, + { transaction }, + ); + await queryInterface.addIndex( + 'Referrals', + ['eth_chain_id', 'transaction_hash'], + { + unique: true, + transaction, + }, + ); + + await queryInterface.addIndex('Referrals', ['referee_address'], { + transaction, + }); + await queryInterface.addIndex('Referrals', ['referrer_address'], { + transaction, + }); + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.dropTable('ReferralFees', { transaction }); + await queryInterface.dropTable('Referrals', { transaction }); + await queryInterface.removeColumn('Users', 'referral_eth_earnings', { + transaction, + }); + await queryInterface.addColumn('Users', 'referral_link', { + type: Sequelize.STRING, + allowNull: true, + }); + }); + }, +};