diff --git a/libs/adapters/src/rabbitmq/configuration/rascalConfig.ts b/libs/adapters/src/rabbitmq/configuration/rascalConfig.ts index 5dc68fc48c6..ebb5352cee8 100644 --- a/libs/adapters/src/rabbitmq/configuration/rascalConfig.ts +++ b/libs/adapters/src/rabbitmq/configuration/rascalConfig.ts @@ -234,7 +234,7 @@ export function getAllRascalConfigs( source: RascalExchanges.MessageRelayer, destination: RascalQueues.UserReferrals, destinationType: 'queue', - bindingKeys: [RascalRoutingKeys.UserReferralsCommunityJoined], + bindingKeys: [RascalRoutingKeys.UserReferralsCommunityCreated], }, [RascalBindings.FarcasterWorkerPolicy]: { source: RascalExchanges.MessageRelayer, diff --git a/libs/adapters/src/rabbitmq/types.ts b/libs/adapters/src/rabbitmq/types.ts index 668f3f31df5..7f3d4ac4676 100644 --- a/libs/adapters/src/rabbitmq/types.ts +++ b/libs/adapters/src/rabbitmq/types.ts @@ -92,7 +92,7 @@ export enum RascalRoutingKeys { XpProjectionCommentUpvoted = EventNames.CommentUpvoted, XpProjectionUserMentioned = EventNames.UserMentioned, - UserReferralsCommunityJoined = EventNames.CommunityJoined, + UserReferralsCommunityCreated = EventNames.CommunityCreated, FarcasterWorkerPolicyCastCreated = EventNames.FarcasterCastCreated, FarcasterWorkerPolicyReplyCastCreated = EventNames.FarcasterReplyCastCreated, diff --git a/libs/core/src/framework/command.ts b/libs/core/src/framework/command.ts index 3608a64c012..1ed8c8cb31c 100644 --- a/libs/core/src/framework/command.ts +++ b/libs/core/src/framework/command.ts @@ -24,7 +24,7 @@ export const command = async < try { const context: Context = { actor, - payload: validate ? input.parse(payload) : payload, + payload: validate ? await input.parseAsync(payload) : payload, }; for (const fn of auth) { await fn(context); diff --git a/libs/evm-protocols/src/abis/namespaceFactoryAbi.ts b/libs/evm-protocols/src/abis/namespaceFactoryAbi.ts index cd73105ce57..57dd0bf78f8 100644 --- a/libs/evm-protocols/src/abis/namespaceFactoryAbi.ts +++ b/libs/evm-protocols/src/abis/namespaceFactoryAbi.ts @@ -46,6 +46,49 @@ export const namespaceFactoryAbi = [ name: 'DeployedNamespace', type: 'event', }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'address', + name: 'namespaceAdress', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'feeManager', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'referrer', + type: 'address', + }, + { + indexed: false, + internalType: 'address', + name: 'referralFeeManager', + type: 'address', + }, + { + indexed: false, + internalType: 'bytes', + name: 'signature', + type: 'bytes', + }, + { + indexed: false, + internalType: 'address', + name: 'namespaceDeployer', + type: 'address', + }, + ], + name: 'DeployedNamespaceWithReferral', + type: 'event', + }, { anonymous: false, inputs: [ diff --git a/libs/evm-protocols/src/event-registry/eventRegistry.ts b/libs/evm-protocols/src/event-registry/eventRegistry.ts index fa73ac4f329..1c722a18f54 100644 --- a/libs/evm-protocols/src/event-registry/eventRegistry.ts +++ b/libs/evm-protocols/src/event-registry/eventRegistry.ts @@ -68,6 +68,7 @@ const namespaceFactorySource = { eventSignatures: [ EvmEventSignatures.NamespaceFactory.ContestManagerDeployed, EvmEventSignatures.NamespaceFactory.NamespaceDeployed, + EvmEventSignatures.NamespaceFactory.NamespaceDeployedWithReferral, ], childContracts: { [ChildContractNames.RecurringContest]: { diff --git a/libs/evm-protocols/src/event-registry/eventSignatures.ts b/libs/evm-protocols/src/event-registry/eventSignatures.ts index 74640b29a53..7ad074fb034 100644 --- a/libs/evm-protocols/src/event-registry/eventSignatures.ts +++ b/libs/evm-protocols/src/event-registry/eventSignatures.ts @@ -17,6 +17,8 @@ export const EvmEventSignatures = { NamespaceFactory: { NamespaceDeployed: '0x8870ba2202802ce285ce6bead5ac915b6dc2d35c8a9d6f96fa56de9de12829d5', + NamespaceDeployedWithReferral: + '0x2f5d04158abd2b403eb3b099bf1257e7949197015ef7d19db38b2c45f9e0d164', ContestManagerDeployed: '0x990f533044dbc89b838acde9cd2c72c400999871cf8f792d731edcae15ead693', CommunityNamespaceCreated: diff --git a/libs/model/src/community/CreateCommunity.command.ts b/libs/model/src/community/CreateCommunity.command.ts index f7f806f3f68..513a0416dce 100644 --- a/libs/model/src/community/CreateCommunity.command.ts +++ b/libs/model/src/community/CreateCommunity.command.ts @@ -106,6 +106,12 @@ export function CreateCommunity(): Command { else if (base === ChainBase.CosmosSDK && !node.cosmos_chain_id) throw new InvalidInput(CreateCommunityErrors.CosmosChainNameRequired); + const user = await models.User.findOne({ + where: { id: actor.user.id }, + attributes: ['id', 'referred_by_address'], + }); + mustExist('User', user); + // == command transaction boundary == await models.sequelize.transaction(async (transaction) => { await models.Community.create( @@ -148,7 +154,7 @@ export function CreateCommunity(): Command { const created = await models.Address.create( { - user_id: actor.user.id, + user_id: user.id, address: admin_address.address, community_id: id, hex: @@ -176,8 +182,8 @@ export function CreateCommunity(): Command { event_name: schemas.EventNames.CommunityCreated, event_payload: { community_id: id, - user_id: actor.user.id!, - referrer_address: payload.referrer_address, + user_id: user.id!, + referrer_address: user.referred_by_address ?? undefined, created_at: created.created_at!, }, }, diff --git a/libs/model/src/community/GetMembers.query.ts b/libs/model/src/community/GetMembers.query.ts index f2eb486dbb1..7cae9720536 100644 --- a/libs/model/src/community/GetMembers.query.ts +++ b/libs/model/src/community/GetMembers.query.ts @@ -4,10 +4,10 @@ import { QueryTypes } from 'sequelize'; import { z } from 'zod'; import { models } from '../database'; -const buildOrderBy = ( - by: 'name' | 'referrals' | 'earnings' | string, - direction: 'ASC' | 'DESC', -) => { +type OrderBy = 'name' | 'last_active' | 'referrals' | 'earnings'; +type OrderDirection = 'ASC' | 'DESC'; + +const buildOrderBy = (by: OrderBy, direction: OrderDirection) => { switch (by) { case 'name': return `profile_name ${direction}`; @@ -101,7 +101,8 @@ const buildFilteredQuery = ( }; function membersSqlWithoutSearch( - orderBy: string, + by: OrderBy, + direction: OrderDirection, limit: number, offset: number, ) { @@ -112,6 +113,15 @@ function membersSqlWithoutSearch( U.profile->>'name' AS profile_name, U.profile->>'avatar_url' AS avatar_url, U.created_at, + ( + SELECT JSON_BUILD_OBJECT( + 'user_id', RU.id, + 'profile_name', RU.profile->>'name', + 'avatar_url', RU.profile->>'avatar_url' + ) + FROM "Addresses" RA JOIN "Users" RU ON RA.user_id = RU.id + WHERE RA.address = U.referred_by_address LIMIT 1 + ) as referred_by, COALESCE(U.referral_count, 0) AS referral_count, COALESCE(U.referral_eth_earnings, 0) AS referral_eth_earnings, MAX(COALESCE(A.last_active, U.created_at)) AS last_active, @@ -120,15 +130,7 @@ function membersSqlWithoutSearch( 'address', A.address, 'community_id', A.community_id, 'role', A.role, - 'stake_balance', 0, -- TODO: project stake balance here - 'referred_by', (SELECT - JSON_BUILD_OBJECT( - 'user_id', RU.id, - 'profile_name', RU.profile->>'name', - 'avatar_url', RU.profile->>'avatar_url' - ) - FROM "Addresses" RA JOIN "Users" RU on RA.user_id = RU.id - WHERE RA.address = A.referred_by_address LIMIT 1) + 'stake_balance', 0 -- TODO: project stake balance here )) AS addresses, COALESCE(ARRAY_AGG(M.group_id) FILTER (WHERE M.group_id IS NOT NULL), '{}') AS group_ids, T.total @@ -138,15 +140,21 @@ function membersSqlWithoutSearch( JOIN T ON TRUE WHERE A.community_id = :community_id + ${ + by === 'referrals' || by === 'earnings' + ? 'AND COALESCE(U.referral_count, 0) + COALESCE(U.referral_eth_earnings, 0) > 0' + : '' + } GROUP BY U.id, T.total - ORDER BY ${orderBy} + ORDER BY ${buildOrderBy(by, direction)} LIMIT ${limit} OFFSET ${offset}; `; } function membersSqlWithSearch( cte: string, - orderBy: string, + by: OrderBy, + direction: OrderDirection, limit: number, offset: number, ) { @@ -157,6 +165,15 @@ function membersSqlWithSearch( U.profile->>'name' AS profile_name, U.profile->>'avatar_url' AS avatar_url, U.created_at, + ( + SELECT JSON_BUILD_OBJECT( + 'user_id', RU.id, + 'profile_name', RU.profile->>'name', + 'avatar_url', RU.profile->>'avatar_url' + ) + FROM "Addresses" RA JOIN "Users" RU ON RA.user_id = RU.id + WHERE RA.address = U.referred_by_address LIMIT 1 + ) AS referred_by, COALESCE(U.referral_count, 0) AS referral_count, COALESCE(U.referral_eth_earnings, 0) AS referral_eth_earnings, MAX(COALESCE(A.last_active, U.created_at)) AS last_active, @@ -165,15 +182,7 @@ function membersSqlWithSearch( 'address', A.address, 'community_id', A.community_id, 'role', A.role, - 'stake_balance', 0, -- TODO: project stake balance here - 'referred_by', (SELECT - JSON_BUILD_OBJECT( - 'user_id', RU.id, - 'profile_name', RU.profile->>'name', - 'avatar_url', RU.profile->>'avatar_url' - ) - FROM "Addresses" RA JOIN "Users" RU on RA.user_id = RU.id - WHERE RA.address = A.referred_by_address LIMIT 1) + 'stake_balance', 0 -- TODO: project stake balance here )) AS addresses, COALESCE(ARRAY_AGG(M.group_id) FILTER (WHERE M.group_id IS NOT NULL), '{}') AS group_ids, T.total @@ -182,8 +191,13 @@ function membersSqlWithSearch( JOIN "Users" U ON A.user_id = U.id LEFT JOIN "Memberships" M ON A.id = M.address_id AND M.reject_reason IS NULL JOIN T ON TRUE + ${ + by === 'referrals' || by === 'earnings' + ? 'WHERE COALESCE(U.referral_count, 0) + COALESCE(U.referral_eth_earnings, 0) > 0' + : '' + } GROUP BY U.id, T.total - ORDER BY ${orderBy} + ORDER BY ${buildOrderBy(by, direction)} LIMIT ${limit} OFFSET ${offset}; `; } @@ -214,10 +228,8 @@ export function GetMembers(): Query { addresses, }; - const orderBy = buildOrderBy( - order_by ?? 'name', - order_direction ?? 'DESC', - ); + const by = order_by ?? 'name'; + const direction = order_direction ?? 'DESC'; const sql = search || memberships || addresses.length > 0 @@ -226,11 +238,12 @@ export function GetMembers(): Query { search ?? '', buildFilters(memberships ?? '', addresses), ), - orderBy, + by, + direction, limit, offset, ) - : membersSqlWithoutSearch(orderBy, limit, offset); + : membersSqlWithoutSearch(by, direction, limit, offset); const members = await models.sequelize.query< z.infer & { total?: number } diff --git a/libs/model/src/community/JoinCommunity.command.ts b/libs/model/src/community/JoinCommunity.command.ts index e8fdc583692..89a4f9fac47 100644 --- a/libs/model/src/community/JoinCommunity.command.ts +++ b/libs/model/src/community/JoinCommunity.command.ts @@ -98,7 +98,6 @@ export function JoinCommunity(): Command { is_user_default: false, ghost_address: false, is_banned: false, - referred_by_address: payload.referrer_address, }, { transaction }, ); @@ -115,7 +114,6 @@ export function JoinCommunity(): Command { event_payload: { community_id, user_id: actor.user.id!, - referrer_address: payload.referrer_address, created_at: created.created_at!, }, }, diff --git a/libs/model/src/models/address.ts b/libs/model/src/models/address.ts index be524f684f7..aa8e273340c 100644 --- a/libs/model/src/models/address.ts +++ b/libs/model/src/models/address.ts @@ -46,7 +46,6 @@ export default ( created_at: { type: Sequelize.DATE, allowNull: false }, updated_at: { type: Sequelize.DATE, allowNull: false }, user_id: { type: Sequelize.INTEGER, allowNull: true }, - referred_by_address: { type: Sequelize.STRING, allowNull: true }, ghost_address: { type: Sequelize.BOOLEAN, allowNull: false, diff --git a/libs/model/src/models/referral_fee.ts b/libs/model/src/models/referral_fee.ts index 78d5c80382d..e89c9f26e72 100644 --- a/libs/model/src/models/referral_fee.ts +++ b/libs/model/src/models/referral_fee.ts @@ -36,6 +36,10 @@ export const ReferralFee = ( type: Sequelize.FLOAT, allowNull: false, }, + referee_address: { + type: Sequelize.STRING, + allowNull: false, + }, transaction_timestamp: { type: Sequelize.BIGINT, allowNull: false, diff --git a/libs/model/src/models/user.ts b/libs/model/src/models/user.ts index 207eb1641de..9e07cd4a367 100644 --- a/libs/model/src/models/user.ts +++ b/libs/model/src/models/user.ts @@ -75,6 +75,7 @@ export default (sequelize: Sequelize.Sequelize): UserModelStatic => profile: { type: Sequelize.JSONB, allowNull: false }, xp_points: { type: Sequelize.INTEGER, defaultValue: 0, allowNull: true }, unsubscribe_uuid: { type: Sequelize.STRING, allowNull: true }, + referred_by_address: { type: Sequelize.STRING, allowNull: true }, referral_count: { type: Sequelize.INTEGER, defaultValue: 0, diff --git a/libs/model/src/policies/ChainEventCreated.policy.ts b/libs/model/src/policies/ChainEventCreated.policy.ts index 5f06f296749..1703f67eba6 100644 --- a/libs/model/src/policies/ChainEventCreated.policy.ts +++ b/libs/model/src/policies/ChainEventCreated.policy.ts @@ -7,8 +7,8 @@ import { systemActor } from '../middleware'; import { CreateLaunchpadToken } from '../token/CreateToken.command'; import { handleCommunityStakeTrades } from './handlers/handleCommunityStakeTrades'; import { handleLaunchpadTrade } from './handlers/handleLaunchpadTrade'; +import { handleNamespaceDeployedWithReferral } from './handlers/handleNamespaceDeployedWithReferral'; import { handleReferralFeeDistributed } from './handlers/handleReferralFeeDistributed'; -import { handleReferralSet } from './handlers/handleReferralSet'; const log = logger(import.meta); @@ -17,8 +17,12 @@ export const processChainEventCreated: EventHandler< ZodUndefined > = async ({ payload }) => { switch (payload.eventSource.eventSignature) { + case EvmEventSignatures.NamespaceFactory.NamespaceDeployedWithReferral: + await handleNamespaceDeployedWithReferral(payload); + break; + case EvmEventSignatures.CommunityStake.Trade: - await handleCommunityStakeTrades(models, payload); + await handleCommunityStakeTrades(payload); break; case EvmEventSignatures.Launchpad.TokenLaunched: { @@ -43,7 +47,7 @@ export const processChainEventCreated: EventHandler< break; case EvmEventSignatures.Referrals.ReferralSet: - await handleReferralSet(payload); + // await handleReferralSet(payload); break; case EvmEventSignatures.Referrals.FeeDistributed: diff --git a/libs/model/src/policies/handlers/handleCommunityStakeTrades.ts b/libs/model/src/policies/handlers/handleCommunityStakeTrades.ts index 0ff9736213a..5376a6e1908 100644 --- a/libs/model/src/policies/handlers/handleCommunityStakeTrades.ts +++ b/libs/model/src/policies/handlers/handleCommunityStakeTrades.ts @@ -3,13 +3,12 @@ import { getStakeTradeInfo } from '@hicommonwealth/evm-protocols'; import { chainEvents, events } from '@hicommonwealth/schemas'; import { BigNumber } from 'ethers'; import { z } from 'zod'; -import { DB } from '../../models'; +import { models } from '../../database'; import { chainNodeMustExist } from '../utils/utils'; const log = logger(import.meta); export async function handleCommunityStakeTrades( - models: DB, event: z.infer, ) { const { diff --git a/libs/model/src/policies/handlers/handleNamespaceDeployedWithReferral.ts b/libs/model/src/policies/handlers/handleNamespaceDeployedWithReferral.ts new file mode 100644 index 00000000000..c9ed1c88266 --- /dev/null +++ b/libs/model/src/policies/handlers/handleNamespaceDeployedWithReferral.ts @@ -0,0 +1,75 @@ +import { chainEvents, events } from '@hicommonwealth/schemas'; +import { z } from 'zod'; +import { models } from '../../database'; + +async function setReferral( + timestamp: number, + eth_chain_id: number, + transaction_hash: string, + namespace_address: string, + referee_address: string, + referrer_address: string, + log_removed: boolean, +) { + const existingReferral = await models.Referral.findOne({ + where: { referee_address, referrer_address }, + }); + if (existingReferral) { + if ( + existingReferral.transaction_hash === transaction_hash && + existingReferral.eth_chain_id === eth_chain_id + ) { + // found with txn but removed from chain + if (log_removed) + await existingReferral.update({ + eth_chain_id: null, + transaction_hash: null, + namespace_address: null, + created_on_chain_timestamp: null, + }); + } else { + // found with partial or outdated chain details + await existingReferral.update({ + eth_chain_id, + transaction_hash, + namespace_address, + created_on_chain_timestamp: Number(timestamp), + }); + } + } else + await models.Referral.create({ + eth_chain_id, + transaction_hash, + namespace_address, + referee_address, + referrer_address, + referrer_received_eth_amount: 0, + created_on_chain_timestamp: Number(timestamp), + }); +} + +export async function handleNamespaceDeployedWithReferral( + event: z.infer, +) { + const { + 0: namespace_address, + // 1: fee_manager_address, + 2: referrer_address, + // 3: referral_fee_manager_contract_address, + // 4: signature, + 5: referee_address, + } = event.parsedArgs as z.infer< + typeof chainEvents.NamespaceDeployedWithReferral + >; + + if (referrer_address) + await setReferral( + event.block.timestamp, + event.eventSource.ethChainId, + event.rawLog.transactionHash, + namespace_address, + referee_address, + referrer_address, + event.rawLog.removed, + ); +} diff --git a/libs/model/src/policies/handlers/handleReferralFeeDistributed.ts b/libs/model/src/policies/handlers/handleReferralFeeDistributed.ts index 873812a904d..71d443d13e7 100644 --- a/libs/model/src/policies/handlers/handleReferralFeeDistributed.ts +++ b/libs/model/src/policies/handlers/handleReferralFeeDistributed.ts @@ -8,11 +8,11 @@ export async function handleReferralFeeDistributed( event: z.infer, ) { const { - 0: namespaceAddress, - 1: tokenAddress, - // 2: totalAmountDistributed, - 3: referrerAddress, - 4: referrerReceivedAmount, + 0: namespace_address, + 1: distributed_token_address, + // 2: total_amount_distributed, + 3: referrer_address, + 4: fee_amount, } = event.parsedArgs as z.infer; const existingFee = await models.ReferralFee.findOne({ @@ -21,53 +21,54 @@ export async function handleReferralFeeDistributed( transaction_hash: event.rawLog.transactionHash, }, }); - - if (event.rawLog.removed && existingFee) { - await existingFee.destroy(); + if (existingFee) { + event.rawLog.removed && (await existingFee.destroy()); return; - } else if (existingFee) return; + } + + // find the referral (already mapped to a namespace) + const referral = await models.Referral.findOne({ + where: { + referrer_address, + namespace_address, + }, + }); + if (!referral) return; // we must guarantee the order of chain events here - const feeAmount = - Number(BigNumber.from(referrerReceivedAmount).toBigInt()) / 1e18; + const referrer_received_amount = + Number(BigNumber.from(fee_amount).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, + namespace_address, + distributed_token_address, + referrer_recipient_address: referrer_address, + referrer_received_amount, + referee_address: referral.referee_address, transaction_timestamp: Number(event.block.timestamp), }, { transaction }, ); // if native token i.e. ETH - if (tokenAddress === ZERO_ADDRESS) { - const userAddress = await models.Address.findOne({ - where: { - address: referrerAddress, - }, + if (distributed_token_address === ZERO_ADDRESS) { + const referrer = await models.Address.findOne({ + where: { address: referrer_address }, transaction, }); - if (userAddress) { + if (referrer) { await models.User.increment('referral_eth_earnings', { - by: feeAmount, - where: { - id: userAddress.user_id!, - }, + by: referrer_received_amount, + where: { id: referrer.user_id! }, transaction, }); } - await models.Referral.increment('referrer_received_eth_amount', { - by: feeAmount, - where: { - referrer_address: referrerAddress, - referee_address: event.rawLog.address, - }, + await referral.increment('referrer_received_eth_amount', { + by: referrer_received_amount, transaction, }); } diff --git a/libs/model/src/policies/handlers/handleReferralSet.ts b/libs/model/src/policies/handlers/handleReferralSet.ts index f554d3703bf..f5c1c985d03 100644 --- a/libs/model/src/policies/handlers/handleReferralSet.ts +++ b/libs/model/src/policies/handlers/handleReferralSet.ts @@ -2,6 +2,7 @@ import { chainEvents, events } from '@hicommonwealth/schemas'; import { z } from 'zod'; import { models } from '../../database'; +// TODO: remove this handler since it's redundant with handleNamespaceDeployed export async function handleReferralSet( event: z.infer, ) { diff --git a/libs/model/src/user/GetUserProfile.query.ts b/libs/model/src/user/GetUserProfile.query.ts index 94fbc19a539..1096f359c08 100644 --- a/libs/model/src/user/GetUserProfile.query.ts +++ b/libs/model/src/user/GetUserProfile.query.ts @@ -16,7 +16,13 @@ export function GetUserProfile(): Query { const user = await models.User.findOne({ where: { id: user_id }, - attributes: ['profile', 'xp_points'], + attributes: [ + 'profile', + 'referred_by_address', + 'referral_count', + 'referral_eth_earnings', + 'xp_points', + ], }); mustExist('User', user); @@ -105,6 +111,9 @@ export function GetUserProfile(): Query { isOwner: actor.user?.id === user_id, // ensure Tag is present in typed response tags: profileTags.map((t) => ({ id: t.Tag!.id!, name: t.Tag!.name })), + referred_by_address: user!.referred_by_address, + referral_count: user!.referral_count ?? 0, + referral_eth_earnings: user!.referral_eth_earnings ?? 0, xp_points: user!.xp_points ?? 0, }; }, diff --git a/libs/model/src/user/GetUserReferralFees.query.ts b/libs/model/src/user/GetUserReferralFees.query.ts index 5045bb92b73..9c55e5fe128 100644 --- a/libs/model/src/user/GetUserReferralFees.query.ts +++ b/libs/model/src/user/GetUserReferralFees.query.ts @@ -17,28 +17,34 @@ export function GetUserReferralFees(): Query< >( ` WITH -referrer_addresses AS ( - SELECT DISTINCT address - FROM "Addresses" +R AS ( + SELECT DISTINCT address FROM "Addresses" WHERE user_id = :user_id AND address LIKE '0x%' ) SELECT - eth_chain_id, - transaction_hash, - namespace_address, - distributed_token_address, - referrer_recipient_address, - referrer_received_amount, - CAST(transaction_timestamp AS DOUBLE PRECISION) as transaction_timestamp -FROM "ReferralFees" -WHERE referrer_recipient_address IN (SELECT * FROM referrer_addresses); + F.eth_chain_id, + F.transaction_hash, + F.namespace_address, + F.distributed_token_address, + F.referrer_recipient_address, + F.referrer_received_amount, + CAST(F.transaction_timestamp AS DOUBLE PRECISION) as transaction_timestamp, + F.referee_address, + C.id AS community_id, + C.name AS community_name, + C.icon_url AS community_icon_url, + U.profile AS referee_profile +FROM + "ReferralFees" F + JOIN R ON F.referrer_recipient_address = R.address + LEFT JOIN "Communities" C ON F.namespace_address = C.namespace_address + LEFT JOIN "Addresses" A ON A.community_id = C.id AND A.address = F.referee_address + LEFT JOIN "Users" U ON U.id = A.user_id; `, { type: QueryTypes.SELECT, raw: true, - replacements: { - user_id: actor.user.id, - }, + replacements: { user_id: actor.user.id }, }, ); }, diff --git a/libs/model/src/user/GetUserReferrals.query.ts b/libs/model/src/user/GetUserReferrals.query.ts index 31b77c3cf84..274a478a2f1 100644 --- a/libs/model/src/user/GetUserReferrals.query.ts +++ b/libs/model/src/user/GetUserReferrals.query.ts @@ -43,10 +43,15 @@ referee_addresses AS ( SELECT R.*, U.id as referee_user_id, - U.profile as referee_profile + U.profile as referee_profile, + C.id as community_id, + C.name as community_name, + C.icon_url as community_icon_url FROM referrals R JOIN referee_addresses RA ON RA.address = R.referee_address - JOIN "Users" U ON U.id = RA.user_id; + JOIN "Users" U ON U.id = RA.user_id + LEFT JOIN "Communities" C ON C.namespace = R.namespace_address + ; `, { type: QueryTypes.SELECT, diff --git a/libs/model/src/user/SignIn.command.ts b/libs/model/src/user/SignIn.command.ts index ef420b2c2e2..e43b6c8bb7c 100644 --- a/libs/model/src/user/SignIn.command.ts +++ b/libs/model/src/user/SignIn.command.ts @@ -99,7 +99,11 @@ export function SignIn(): Command { }); if (!existing) { const user = await models.User.create( - { email: null, profile: {} }, + { + email: null, + profile: {}, + referred_by_address: referrer_address, + }, { transaction }, ); if (!user) throw new Error('Failed to create user'); @@ -125,7 +129,6 @@ export function SignIn(): Command { is_user_default: false, ghost_address: false, is_banned: false, - referred_by_address: referrer_address, }, transaction, }); @@ -150,7 +153,6 @@ export function SignIn(): Command { community_id, user_id: addr.user_id!, created_at: addr.created_at!, - referrer_address, }, }); new_user && diff --git a/libs/model/src/user/UserReferrals.projection.ts b/libs/model/src/user/UserReferrals.projection.ts index 6e4607ba013..f17358480a4 100644 --- a/libs/model/src/user/UserReferrals.projection.ts +++ b/libs/model/src/user/UserReferrals.projection.ts @@ -3,64 +3,61 @@ import { events } from '@hicommonwealth/schemas'; import { models } from '../database'; const inputs = { - CommunityJoined: events.CommunityJoined, + CommunityCreated: events.CommunityCreated, }; +async function setReferral( + user_id: number, + community_id: string, + referrer_address: string, +) { + const referee = await models.Address.findOne({ + where: { user_id, community_id }, + attributes: ['id', 'address'], + }); + if (!referee) return; + + await models.sequelize.transaction(async (transaction) => { + await models.Referral.findOrCreate({ + where: { referee_address: referee.address, referrer_address }, + defaults: { + referee_address: referee.address, + referrer_address, + referrer_received_eth_amount: 0, + }, + transaction, + }); + + // increment the referral count of referrer in this community + const referrer = await models.User.findOne({ + include: [ + { + model: models.Address, + where: { address: referrer_address }, + }, + ], + transaction, + }); + referrer && + (await referrer.update( + { + referral_count: models.sequelize.literal( + 'coalesce(referral_count, 0) + 1', + ), + }, + { transaction }, + )); + }); +} + export function UserReferrals(): Projection { return { inputs, body: { - CommunityJoined: async ({ payload }) => { - const { referrer_address } = payload; + CommunityCreated: async ({ payload }) => { + const { user_id, community_id, referrer_address } = payload; if (!referrer_address) return; - - const refereeAddress = await models.Address.findOne({ - where: { - user_id: payload.user_id, - community_id: payload.community_id, - }, - attributes: ['id', 'address'], - }); - if (!refereeAddress) return; - - await models.sequelize.transaction(async (transaction) => { - await models.Referral.findOrCreate({ - where: { - referee_address: refereeAddress.address, - referrer_address, - }, - defaults: { - referee_address: refereeAddress.address, - referrer_address, - referrer_received_eth_amount: 0, - }, - transaction, - }); - - // increment the referral count of referrer in this community - const referrerUser = await models.User.findOne({ - include: [ - { - model: models.Address, - attributes: ['id', 'address'], - where: { - address: referrer_address, - community_id: payload.community_id, - }, - }, - ], - transaction, - }); - if (referrerUser) - await referrerUser.update( - { - referral_count: models.sequelize.literal( - 'coalesce(referral_count, 0) + 1', - ), - }, - { transaction }, - ); - }); + await setReferral(user_id, community_id, referrer_address); }, }, }; diff --git a/libs/model/src/user/Xp.projection.ts b/libs/model/src/user/Xp.projection.ts index 896f7cd0521..fa761577eef 100644 --- a/libs/model/src/user/Xp.projection.ts +++ b/libs/model/src/user/Xp.projection.ts @@ -224,8 +224,8 @@ export function Xp(): Projection { const reward_amount = 20; const creator_reward_weight = 0.2; - const referee_address = await models.Address.findOne({ - where: { address: payload.address, user_id: payload.user_id }, + const referee_address = await models.User.findOne({ + where: { id: payload.user_id }, }); referee_address && referee_address.referred_by_address && @@ -257,12 +257,15 @@ export function Xp(): Projection { payload, 'CommunityJoined', ); + const user = await models.User.findOne({ + where: { id: payload.user_id }, + }); if (action_metas.length > 0) { await recordXpsForQuest( payload.user_id, payload.created_at!, action_metas, - payload.referrer_address, + user?.referred_by_address, ); } }, diff --git a/libs/model/test/referral/referral-lifecycle.spec.ts b/libs/model/test/referral/referral-lifecycle.spec.ts index 65dbcf77add..55551e0d7c6 100644 --- a/libs/model/test/referral/referral-lifecycle.spec.ts +++ b/libs/model/test/referral/referral-lifecycle.spec.ts @@ -2,12 +2,13 @@ import { BigNumber } from '@ethersproject/bignumber'; import { Actor, command, dispose, query } from '@hicommonwealth/core'; import { EvmEventSignatures } from '@hicommonwealth/evm-protocols'; import * as schemas from '@hicommonwealth/schemas'; -import { ZERO_ADDRESS } from '@hicommonwealth/shared'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { ChainBase, ChainType, ZERO_ADDRESS } from '@hicommonwealth/shared'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; -import { JoinCommunity } from '../../src/community'; +import { CreateCommunity, UpdateCommunity } from '../../src/community'; import { models } from '../../src/database'; import { ChainEventPolicy } from '../../src/policies'; +import { commonProtocol } from '../../src/services'; import { seed } from '../../src/tester'; import { GetUserReferralFees, UserReferrals } from '../../src/user'; import { GetUserReferrals } from '../../src/user/GetUserReferrals.query'; @@ -58,23 +59,27 @@ function chainEvent( describe('Referral lifecycle', () => { let admin: Actor; let nonMember: Actor; - let community_id: string; + let nonMemberUser: z.infer | undefined; + let chain_node_id: number; beforeAll(async () => { const { actors, base, community } = await seedCommunity({ roles: ['admin', 'member'], }); admin = actors.admin; - const [nonMemberUser] = await seed('User', { + [nonMemberUser] = await seed('User', { profile: { name: 'non-member', }, + referred_by_address: admin.address, // referrer isAdmin: false, is_welcome_onboard_flow_complete: false, }); const [nonMemberAddress] = await seed('Address', { community_id: base!.id!, user_id: nonMemberUser!.id!, + address: '0x0000000000000000000000000000000000001234', + verified: true, // must be verified to update community as admin }); nonMember = { user: { @@ -84,25 +89,37 @@ describe('Referral lifecycle', () => { }, address: nonMemberAddress!.address!, }; - community_id = community!.id!; + chain_node_id = community!.chain_node_id!; }); afterAll(async () => { await dispose()(); }); - it('should create a referral when signing in with a referral link', async () => { - // non-member joins with referral link - await command(JoinCommunity(), { + it('should create referral/fees when referred user creates a community', async () => { + // non-member creates a community with a referral link from admin + const result = await command(CreateCommunity(), { actor: nonMember, payload: { - community_id, - referrer_address: admin.address, + id: 'referred-community', + name: 'Referred Community', + description: 'Referred Community Description', + default_symbol: 'RC', + base: ChainBase.Ethereum, + type: ChainType.Offchain, + chain_node_id, + directory_page_enabled: true, + social_links: [], + tags: [], }, }); + expect(result).toBeTruthy(); + const community = result?.community; + expect(community).toBeTruthy(); + const community_id = community!.id!; // creates "partial" platform entries for referrals - await drainOutbox(['CommunityJoined'], UserReferrals); + await drainOutbox(['CommunityCreated'], UserReferrals); const expectedReferrals: z.infer[] = [ { @@ -117,6 +134,9 @@ describe('Referral lifecycle', () => { updated_at: expect.any(Date), referee_user_id: nonMember.user.id!, referee_profile: { name: 'non-member' }, + community_id: null, + community_name: null, + community_icon_url: null, }, ]; @@ -132,27 +152,41 @@ describe('Referral lifecycle', () => { }); expect(referrerUser?.referral_count).toBe(1); - const refereeAddress = await models.Address.findOne({ - where: { user_id: nonMember.user.id, community_id }, - }); - expect(refereeAddress?.referred_by_address).toBe(admin.address); - - // simulate on-chain transactions that occur when referees - // deploy a new namespace with a referral link (ReferralSet) + // simulate namespace creation on-chain (From the UI) const namespaceAddress = '0x0000000000000000000000000000000000000001'; + const transactionHash = '0x2'; const chainEvents1 = [ chainEvent( - '0x2', - nonMember.address!, // referee - EvmEventSignatures.Referrals.ReferralSet, + transactionHash, + '0x0000000000000000000000000000000000000002', + EvmEventSignatures.NamespaceFactory.NamespaceDeployedWithReferral, [ namespaceAddress, + '0x0000000000000000000000000000000000000004', // fee manager address admin.address, // referrer + '0x0000000000000000000000000000000000000003', // referral fee contract + '0x0', // signature + nonMember.address!, // referee ], ), ]; await models.Outbox.bulkCreate(chainEvents1); + // simulate UI updating the namespace address + vi.spyOn( + commonProtocol.newNamespaceValidator, + 'validateNamespace', + ).mockResolvedValue(namespaceAddress); + await command(UpdateCommunity(), { + actor: nonMember, + payload: { + community_id, + transactionHash, + namespace: namespaceAddress, + }, + }); + vi.restoreAllMocks(); + // syncs "partial" platform entries for referrals with on-chain transactions await drainOutbox(['ChainEventCreated'], ChainEventPolicy); @@ -161,6 +195,9 @@ describe('Referral lifecycle', () => { expectedReferrals[0].namespace_address = namespaceAddress; expectedReferrals[0].created_on_chain_timestamp = chainEvents1[0].event_payload.block.timestamp; + expectedReferrals[0].community_id = community!.id; + expectedReferrals[0].community_name = community!.name; + expectedReferrals[0].community_icon_url = community!.icon_url; // get referrals again with tx attributes const referrals2 = await query(GetUserReferrals(), { @@ -212,6 +249,14 @@ describe('Referral lifecycle', () => { referrer_recipient_address: admin.address, referrer_received_amount: fee, transaction_timestamp: expect.any(Number), + referee_address: nonMember.address!, + referee_profile: { + name: nonMemberUser?.profile.name, + avatar_url: nonMemberUser?.profile.avatar_url, + }, + community_id, + community_name: community!.name, + community_icon_url: community!.icon_url, }, ]; const referralFees = await query(GetUserReferralFees(), { diff --git a/libs/schemas/src/commands/community.schemas.ts b/libs/schemas/src/commands/community.schemas.ts index 5a69c15af30..782a908766a 100644 --- a/libs/schemas/src/commands/community.schemas.ts +++ b/libs/schemas/src/commands/community.schemas.ts @@ -48,7 +48,6 @@ export const CreateCommunity = { // hidden optional params token_name: z.string().optional(), - referrer_address: z.string().optional(), // deprecated params to be removed default_symbol: z.string().max(9), @@ -342,7 +341,6 @@ export const SelectCommunity = { export const JoinCommunity = { input: z.object({ community_id: z.string(), - referrer_address: z.string().optional(), }), output: z.object({ community_id: z.string(), diff --git a/libs/schemas/src/entities/referral.schemas.ts b/libs/schemas/src/entities/referral.schemas.ts index aec1724202e..601e89ffd7b 100644 --- a/libs/schemas/src/entities/referral.schemas.ts +++ b/libs/schemas/src/entities/referral.schemas.ts @@ -57,6 +57,7 @@ export const ReferralFees = z.object({ referrer_received_amount: z .number() .describe('The amount of ETH received by the referrer'), + referee_address: z.string().describe('The address of the referee'), 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 a03d35f79bf..36c6dea4ad6 100644 --- a/libs/schemas/src/entities/user.schemas.ts +++ b/libs/schemas/src/entities/user.schemas.ts @@ -52,12 +52,13 @@ export const User = z.object({ is_welcome_onboard_flow_complete: z.boolean().default(false).optional(), profile: UserProfile, - xp_points: PG_INT.default(0).nullish(), unsubscribe_uuid: z.string().uuid().nullish(), + referred_by_address: z.string().max(255).nullish(), referral_count: PG_INT.default(0) .nullish() .describe('Number of referrals that have earned ETH'), referral_eth_earnings: z.number().optional(), + xp_points: PG_INT.default(0).nullish(), ProfileTags: z.array(ProfileTags).optional(), ApiKey: ApiKey.optional(), @@ -75,7 +76,6 @@ export const Address = z.object({ verification_token_expires: z.date().nullish(), verified: z.date().nullish(), last_active: z.date().nullish(), - referred_by_address: z.string().max(255).nullish(), ghost_address: z.boolean().default(false), wallet_id: z.nativeEnum(WalletId).nullish(), block_info: z.string().max(255).nullish(), @@ -111,17 +111,17 @@ export const CommunityMember = z.object({ address: z.string(), stake_balance: z.number().nullish(), role: z.enum(Roles), - referred_by: z - .object({ - user_id: PG_INT, - profile_name: z.string().nullish(), - avatar_url: z.string().nullish(), - }) - .nullish(), }), ), group_ids: z.array(PG_INT), last_active: z.any().nullish().describe('string or date'), + referred_by: z + .object({ + user_id: PG_INT, + profile_name: z.string().nullish(), + avatar_url: z.string().nullish(), + }) + .nullish(), referral_count: PG_INT.default(0).nullish(), referral_eth_earnings: z.number().nullish(), }); diff --git a/libs/schemas/src/events/chain-event.schemas.ts b/libs/schemas/src/events/chain-event.schemas.ts index edd5ddc6664..1c539756b86 100644 --- a/libs/schemas/src/events/chain-event.schemas.ts +++ b/libs/schemas/src/events/chain-event.schemas.ts @@ -17,11 +17,20 @@ export const CommunityStakeTrade = z.tuple([ export const NamespaceDeployed = z.tuple([ z.string().describe('name'), - EVM_ADDRESS.describe('_feeManger'), + EVM_ADDRESS.describe('_feeManager'), z.string().describe('_signature'), EVM_ADDRESS.describe('_namespaceDeployer'), ]); +export const NamespaceDeployedWithReferral = z.tuple([ + EVM_ADDRESS.describe('Namespace address'), + EVM_ADDRESS.describe('Fee manager address of new namespace'), + EVM_ADDRESS.describe('Referrer address (receiving referral fees)'), + EVM_ADDRESS.describe('Referral fee manager contract address'), + z.string().describe('Optional signature for name reservation validation'), + EVM_ADDRESS.describe('Namespace deployer address (referee)'), +]); + export const LaunchpadTokenCreated = z.tuple([ z.string().describe('tokenAddress'), ETHERS_BIG_NUMBER.describe('totalSupply'), @@ -45,13 +54,13 @@ export const ReferralSet = z.tuple([ ]); export const ReferralFeeDistributed = z.tuple([ - EVM_ADDRESS.describe('namespace address'), - EVM_ADDRESS.describe('distributed token address'), + 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)', + 'Total amount of the token that is distributed (includes protocol fee, referral fee, etc)', ), - EVM_ADDRESS.describe("the referrer's address"), + EVM_ADDRESS.describe('Referrer address (recipient)'), ETHERS_BIG_NUMBER.describe( - 'the amount of the token that is distributed to the referrer', + 'The amount of the token distributed to the referrer', ), ]); diff --git a/libs/schemas/src/events/events.schemas.ts b/libs/schemas/src/events/events.schemas.ts index 9a167fccf3f..b7670a4eced 100644 --- a/libs/schemas/src/events/events.schemas.ts +++ b/libs/schemas/src/events/events.schemas.ts @@ -12,6 +12,7 @@ import { LaunchpadTokenCreated, LaunchpadTrade, NamespaceDeployed, + NamespaceDeployedWithReferral, ReferralFeeDistributed, ReferralSet, } from './chain-event.schemas'; @@ -88,7 +89,6 @@ export const CommunityCreated = z.object({ export const CommunityJoined = z.object({ community_id: z.string(), user_id: z.number(), - referrer_address: z.string().nullish(), created_at: z.coerce.date(), }); @@ -215,6 +215,14 @@ export const ChainEventCreated = z.union([ }), parsedArgs: NamespaceDeployed, }), + ChainEventCreatedBase.extend({ + eventSource: ChainEventCreatedBase.shape.eventSource.extend({ + eventSignature: z.literal( + EvmEventSignatures.NamespaceFactory.NamespaceDeployedWithReferral, + ), + }), + parsedArgs: NamespaceDeployedWithReferral, + }), ChainEventCreatedBase.extend({ eventSource: ChainEventCreatedBase.shape.eventSource.extend({ eventSignature: z.literal(EvmEventSignatures.CommunityStake.Trade), diff --git a/libs/schemas/src/queries/user.schemas.ts b/libs/schemas/src/queries/user.schemas.ts index d90b908375e..73f10d817c9 100644 --- a/libs/schemas/src/queries/user.schemas.ts +++ b/libs/schemas/src/queries/user.schemas.ts @@ -33,7 +33,10 @@ export const UserProfileView = z.object({ commentThreads: z.array(ThreadView), isOwner: z.boolean(), tags: z.array(Tags.extend({ id: PG_INT })), - xp_points: z.number().int(), + referred_by_address: z.string().nullish(), + referral_count: PG_INT.default(0), + referral_eth_earnings: z.number().optional(), + xp_points: PG_INT.default(0), }); export const GetUserProfile = { @@ -96,6 +99,9 @@ export const GetUserAddresses = { export const ReferralView = Referral.extend({ referee_user_id: PG_INT, referee_profile: UserProfile, + community_id: z.string().nullish(), + community_name: z.string().nullish(), + community_icon_url: z.string().nullish(), }); export const GetUserReferrals = { @@ -103,7 +109,12 @@ export const GetUserReferrals = { output: z.array(ReferralView), }; -export const ReferralFeesView = ReferralFees; +export const ReferralFeesView = ReferralFees.extend({ + referee_profile: UserProfile.nullish(), + community_id: z.string().nullish(), + community_name: z.string().nullish(), + community_icon_url: z.string().nullish(), +}); export const GetUserReferralFees = { input: z.object({}), diff --git a/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityStakeStep/SignStakeTransactions/SignStakeTransactions.tsx b/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityStakeStep/SignStakeTransactions/SignStakeTransactions.tsx index df8e85da3a6..54cf2393192 100644 --- a/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityStakeStep/SignStakeTransactions/SignStakeTransactions.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityStakeStep/SignStakeTransactions/SignStakeTransactions.tsx @@ -33,9 +33,7 @@ const SignStakeTransactions = ({ apiCallEnabled: user.isLoggedIn, }); - const referrerAddress = profile?.addresses.find( - (address) => address.address === selectedAddress.address, - )?.referred_by_address; + const referrerAddress = profile?.referred_by_address; const { handleReserveCommunityNamespace, reserveNamespaceData } = useReserveCommunityNamespace({ diff --git a/packages/commonwealth/server/migrations/20250117120000-add-referee-to-referral-fees.js b/packages/commonwealth/server/migrations/20250117120000-add-referee-to-referral-fees.js new file mode 100644 index 00000000000..d57657b8ab4 --- /dev/null +++ b/packages/commonwealth/server/migrations/20250117120000-add-referee-to-referral-fees.js @@ -0,0 +1,23 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn( + 'ReferralFees', + 'referee_address', + { + type: Sequelize.STRING, + allowNull: false, + defaultValue: '', + }, + { transaction }, + ); + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('ReferralFees', 'referee_address'); + }, +}; diff --git a/packages/commonwealth/server/migrations/20250121090000-update-referral-stats2.js b/packages/commonwealth/server/migrations/20250121090000-update-referral-stats2.js new file mode 100644 index 00000000000..a035e9482ce --- /dev/null +++ b/packages/commonwealth/server/migrations/20250121090000-update-referral-stats2.js @@ -0,0 +1,38 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn( + 'Users', + 'referred_by_address', + { + type: Sequelize.STRING, + allowNull: true, + }, + { transaction }, + ); + await queryInterface.removeColumn('Addresses', 'referred_by_address', { + transaction, + }); + }); + }, + + async down(queryInterface) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.removeColumn('Users', 'referred_by_address', { + transaction, + }); + await queryInterface.addColumn( + 'Addresses', + 'referred_by_address', + { + type: Sequelize.STRING, + allowNull: true, + }, + { transaction }, + ); + }); + }, +};