diff --git a/src/actions/actionMatchers.ts b/src/actions/actionMatchers.ts new file mode 100644 index 000000000..ba1e29582 --- /dev/null +++ b/src/actions/actionMatchers.ts @@ -0,0 +1,53 @@ +import { ContractEventsSignatures } from '~types'; +import { handleExpenditureAddedAction } from './handlers/expenditureAdded'; +import { handleMintTokensAction } from './handlers/mintTokens'; +import { handlePaymentAction } from './handlers/payment'; +import { ActionMatcher } from './types'; + +export const actionMatchers: ActionMatcher[] = [ + { + eventSignatures: [ContractEventsSignatures.TokensMinted], + handler: handleMintTokensAction, + }, + { + matcherFn: (events) => { + if ( + events[0].signature !== ContractEventsSignatures.ExpenditureAdded || + events[events.length - 1].signature !== + ContractEventsSignatures.OneTxPaymentMade + ) { + return false; + } + + const remainingEvents = events.slice(1, -1); + + if ( + remainingEvents.some((event) => + [ + ContractEventsSignatures.ExpenditureAdded, + ContractEventsSignatures.OneTxPaymentMade, + ].includes(event.signature as ContractEventsSignatures), + ) + ) { + return false; + } + + // @TODO: Adding another listener has a potential to break this + return remainingEvents.every((event) => + [ + ContractEventsSignatures.ColonyFundsMovedBetweenFundingPots, + ContractEventsSignatures.ExpenditureStateChanged, + ContractEventsSignatures.ExpenditurePayoutSet, + ContractEventsSignatures.ExpenditureFinalized, + ContractEventsSignatures.ExpenditureRecipientSet, + ContractEventsSignatures.ExpenditurePayoutClaimed, + ].includes(event.signature as ContractEventsSignatures), + ); + }, + handler: handlePaymentAction, + }, + { + eventSignatures: [ContractEventsSignatures.ExpenditureAdded], + handler: handleExpenditureAddedAction, + }, +]; diff --git a/src/actions/handlers/expenditureAdded.ts b/src/actions/handlers/expenditureAdded.ts new file mode 100644 index 000000000..42db1c797 --- /dev/null +++ b/src/actions/handlers/expenditureAdded.ts @@ -0,0 +1,89 @@ +import { ActionHandler } from '~actions/types'; +import { mutate } from '~amplifyClient'; +import { + ColonyActionType, + CreateExpenditureDocument, + CreateExpenditureMutation, + CreateExpenditureMutationVariables, + ExpenditureStatus, + ExpenditureType, + NotificationType, +} from '~graphql'; +// @TODO: Move closer to the handler +import { getExpenditure } from '~handlers/expenditures/helpers'; +import { + getDomainDatabaseId, + getExpenditureDatabaseId, + output, + toNumber, + verbose, + writeActionFromEvent, +} from '~utils'; + +import { sendExpenditureUpdateNotifications } from '~utils/notifications'; + +export const handleExpenditureAddedAction: ActionHandler = async (events) => { + const { contractAddress: colonyAddress, transactionHash } = events[0]; + const { agent: ownerAddress, expenditureId } = events[0].args; + const convertedExpenditureId = toNumber(expenditureId); + + const expenditure = await getExpenditure( + colonyAddress, + convertedExpenditureId, + ); + if (!expenditure) { + output( + `Could not get expenditure with ID ${convertedExpenditureId} in colony ${colonyAddress}`, + ); + return; + } + + const domainId = toNumber(expenditure.domainId); + const fundingPotId = toNumber(expenditure.fundingPotId); + + verbose( + 'Expenditure with ID', + convertedExpenditureId, + 'added in Colony:', + colonyAddress, + ); + + const databaseId = getExpenditureDatabaseId( + colonyAddress, + convertedExpenditureId, + ); + + await mutate( + CreateExpenditureDocument, + { + input: { + id: databaseId, + type: ExpenditureType.PaymentBuilder, + colonyId: colonyAddress, + nativeId: convertedExpenditureId, + ownerAddress, + status: ExpenditureStatus.Draft, + slots: [], + nativeFundingPotId: fundingPotId, + nativeDomainId: domainId, + isStaked: false, + balances: [], + }, + }, + ); + + await writeActionFromEvent(events[0], colonyAddress, { + type: ColonyActionType.CreateExpenditure, + initiatorAddress: ownerAddress, + expenditureId: databaseId, + fromDomainId: getDomainDatabaseId(colonyAddress, domainId), + }); + + sendExpenditureUpdateNotifications({ + colonyAddress, + creator: ownerAddress, + notificationType: NotificationType.ExpenditureReadyForReview, + transactionHash, + expenditureID: databaseId, + }); +}; diff --git a/src/actions/handlers/mintTokens.ts b/src/actions/handlers/mintTokens.ts new file mode 100644 index 000000000..d0764b765 --- /dev/null +++ b/src/actions/handlers/mintTokens.ts @@ -0,0 +1,52 @@ +import { Id } from '@colony/colony-js'; + +import { ColonyActionType } from '~graphql'; +import { NotificationCategory } from '~types/notifications'; +import { + writeActionFromEvent, + getColonyTokenAddress, + getDomainDatabaseId, + verbose, +} from '~utils'; +import { sendPermissionsActionNotifications } from '~utils/notifications'; +import { ActionHandler } from '../types'; + +export const handleMintTokensAction: ActionHandler = async (events) => { + const { contractAddress: colonyAddress, transactionHash } = events[0]; + const { + agent: initiatorAddress, + who: recipientAddress, + amount, + } = events[0].args; + + const tokenAddress = await getColonyTokenAddress(colonyAddress); + + if (!tokenAddress) { + verbose(`Unable to find ERC20 token address for colony: ${colonyAddress}`); + return; + } + + if (amount && amount.toString() !== '0') { + await writeActionFromEvent(events[0], colonyAddress, { + type: ColonyActionType.MintTokens, + initiatorAddress, + recipientAddress, + amount: amount.toString(), + tokenAddress, + fromDomainId: getDomainDatabaseId(colonyAddress, Id.RootDomain), + }); + + sendPermissionsActionNotifications({ + creator: initiatorAddress, + colonyAddress, + transactionHash, + notificationCategory: NotificationCategory.Payment, + }); + } else { + verbose( + `Detected Mint Tokens event but its amount was ${ + amount ? amount.toString() : amount + }`, + ); + } +}; diff --git a/src/actions/handlers/payment.ts b/src/actions/handlers/payment.ts new file mode 100644 index 000000000..6ed0d2012 --- /dev/null +++ b/src/actions/handlers/payment.ts @@ -0,0 +1,315 @@ +import { AnyColonyClient } from '@colony/colony-js'; + +import { ActionHandler } from '~actions/types'; +import { BigNumber, utils } from 'ethers'; +import { query } from '~amplifyClient'; +import { + ColonyActionType, + GetColonyExtensionDocument, + GetColonyExtensionQuery, + GetColonyExtensionQueryVariables, +} from '~graphql'; +import provider from '~provider'; +import { ContractEvent, ContractEventsSignatures } from '~types'; +import { NotificationCategory } from '~types/notifications'; +import { + getCachedColonyClient, + getDomainDatabaseId, + mapLogToContractEvent, + notNull, + toNumber, + writeActionFromEvent, + ActionFields, + isColonyAddress, + createFundsClaim, +} from '~utils'; +import { getAmountLessFee, getNetworkInverseFee } from '~utils/networkFee'; +import { sendPermissionsActionNotifications } from '~utils/notifications'; + +const PAYOUT_CLAIMED_SIGNATURE_HASH = utils.id( + ContractEventsSignatures.PayoutClaimed, +); + +const EXPENDITURE_PAYOUT_SET = utils.id( + ContractEventsSignatures.ExpenditurePayoutSet, +); + +enum ExpenditureStatus { + Draft, + Cancelled, + Finalized, + Locked, +} + +interface Expenditure { + status: ExpenditureStatus; + owner: string; + fundingPotId: BigNumber; + domainId: BigNumber; + finalizedTimestamp: BigNumber; + globalClaimDelay: BigNumber; +} + +interface ExpenditureSlot { + recipient: string; + claimDelay: BigNumber; + payoutModifier: BigNumber; + skills: BigNumber[]; +} + +export interface MultiPayment { + amount: string; + networkFee?: string; + tokenAddress: string; + recipientAddress: string; +} + +export const handlePaymentAction: ActionHandler = async (events) => { + const oneTxPaymentEvent = events[0]; + const { contractAddress: extensionAddress } = oneTxPaymentEvent; + + const { data } = + (await query( + GetColonyExtensionDocument, + { id: extensionAddress }, + )) ?? {}; + const { colonyId: colonyAddress = '', version } = + data?.getColonyExtension ?? {}; + + const colonyClient = await getCachedColonyClient(colonyAddress); + if (!colonyClient) { + return; + } + + const networkInverseFee = await getNetworkInverseFee(); + if (!networkInverseFee) { + return; + } + + switch (version) { + case 1: + case 2: + case 3: + case 4: + case 5: + handlerV1ToV5( + oneTxPaymentEvent, + colonyAddress, + colonyClient, + networkInverseFee, + ); + break; + case 6: + handlerV6( + oneTxPaymentEvent, + colonyAddress, + colonyClient, + networkInverseFee, + ); + break; + default: + handlerV6( + oneTxPaymentEvent, + colonyAddress, + colonyClient, + networkInverseFee, + ); + break; + } +}; + +const handlerV1ToV5 = async ( + event: ContractEvent, + colonyAddress: string, + colonyClient: AnyColonyClient, + networkFee: string, +): Promise => { + const { blockNumber } = event; + const [initiatorAddress, paymentOrExpenditureId, nPayments] = event.args; + const receipt = await provider.getTransactionReceipt(event.transactionHash); + + if ((nPayments as BigNumber).eq(1)) { + const [payoutClaimedLog] = receipt.logs.filter( + (log) => PAYOUT_CLAIMED_SIGNATURE_HASH === log.topics[0], + ); + + const payoutClaimedEvent = await mapLogToContractEvent( + payoutClaimedLog, + colonyClient.interface, + ); + + if (!payoutClaimedEvent) { + return; + } + + const { recipient: recipientAddress, domainId } = + await colonyClient.getPayment(paymentOrExpenditureId, { + blockTag: blockNumber, + }); + + const recipientIsColony = await isColonyAddress(recipientAddress); + + const { token: tokenAddress, amount } = payoutClaimedEvent.args; + + const amountLessFee = getAmountLessFee(amount, networkFee); + const fee = BigNumber.from(amount).sub(amountLessFee); + + if (recipientIsColony) { + await createFundsClaim({ + colonyAddress: recipientAddress, + tokenAddress, + amount: amount.toString(), + event, + }); + } + + await writeActionFromEvent(event, colonyAddress, { + type: ColonyActionType.Payment, + fromDomainId: getDomainDatabaseId(colonyAddress, toNumber(domainId)), + tokenAddress, + amount: amountLessFee.toString(), + networkFee: fee.toString(), + initiatorAddress, + recipientAddress, + paymentId: toNumber(paymentOrExpenditureId), + }); + } else { + const expenditure: Expenditure = await colonyClient.getExpenditure( + paymentOrExpenditureId, + { blockTag: blockNumber }, + ); + + const expenditurePayoutLogs = receipt.logs.filter((log) => + log.topics.includes(EXPENDITURE_PAYOUT_SET), + ); + + const expenditurePayoutEvents = await Promise.all( + expenditurePayoutLogs.map((log) => + mapLogToContractEvent(log, colonyClient.interface), + ), + ); + + const payments: MultiPayment[] = await Promise.all( + expenditurePayoutEvents.filter(notNull).map(async ({ args }) => { + const [, expenditureId, slotId, tokenAddress, amount] = args; + const expenditureSlot: ExpenditureSlot = + await colonyClient.getExpenditureSlot(expenditureId, slotId, { + blockTag: blockNumber, + }); + + const amountLessFee = getAmountLessFee(amount, networkFee); + const fee = BigNumber.from(amount).sub(amountLessFee); + + const payment: MultiPayment = { + amount: amount.toString(), + networkFee: fee.toString(), + tokenAddress, + recipientAddress: expenditureSlot.recipient, + }; + return payment; + }), + ); + + await writeActionFromEvent(event, colonyAddress, { + type: ColonyActionType.MultiplePayment, + fromDomainId: getDomainDatabaseId( + colonyAddress, + toNumber(expenditure.domainId), + ), + initiatorAddress, + paymentId: toNumber(paymentOrExpenditureId), + payments, + }); + } +}; + +const handlerV6 = async ( + event: ContractEvent, + colonyAddress: string, + colonyClient: AnyColonyClient, + networkFee: string, +): Promise => { + const { blockNumber, transactionHash } = event; + const [initiatorAddress, expenditureId] = event.args; + const receipt = await provider.getTransactionReceipt(event.transactionHash); + + // multiple OneTxPayments use expenditures at the contract level + const expenditure: Expenditure = await colonyClient.getExpenditure( + expenditureId, + { blockTag: blockNumber }, + ); + + const expenditurePayoutLogs = receipt.logs.filter((log) => + log.topics.includes(EXPENDITURE_PAYOUT_SET), + ); + + const expenditurePayoutEvents = await Promise.all( + expenditurePayoutLogs.map((log) => + mapLogToContractEvent(log, colonyClient.interface), + ), + ); + + const payments: MultiPayment[] = await Promise.all( + expenditurePayoutEvents.filter(notNull).map(async ({ args }) => { + const [, expenditureId, slotId, tokenAddress, amount] = args; + const expenditureSlot: ExpenditureSlot = + await colonyClient.getExpenditureSlot(expenditureId, slotId, { + blockTag: blockNumber, + }); + + const amountLessFee = getAmountLessFee(amount, networkFee); + const fee = BigNumber.from(amount).sub(amountLessFee); + + const payment: MultiPayment = { + amount: amountLessFee.toString(), + networkFee: fee.toString(), + tokenAddress, + recipientAddress: expenditureSlot.recipient, + }; + return payment; + }), + ); + + const hasMultiplePayments = payments.length > 1; + let actionFields: ActionFields = { + type: hasMultiplePayments + ? ColonyActionType.MultiplePayment + : ColonyActionType.Payment, + fromDomainId: getDomainDatabaseId( + colonyAddress, + toNumber(expenditure.domainId), + ), + initiatorAddress, + }; + + if (payments.length === 1) { + const { tokenAddress, amount, recipientAddress, networkFee } = payments[0]; + actionFields = { + ...actionFields, + tokenAddress, + amount, + networkFee, + recipientAddress, + paymentId: toNumber(expenditureId), + }; + } else { + actionFields = { + ...actionFields, + paymentId: toNumber(expenditureId), + payments, + }; + } + + await writeActionFromEvent(event, colonyAddress, actionFields); + + const firstPaymentData = payments?.[0]; + if (firstPaymentData) { + sendPermissionsActionNotifications({ + mentions: [firstPaymentData.recipientAddress], + creator: initiatorAddress, + colonyAddress, + transactionHash, + notificationCategory: NotificationCategory.Payment, + }); + } +}; diff --git a/src/actions/types.ts b/src/actions/types.ts new file mode 100644 index 000000000..6f4c80a5e --- /dev/null +++ b/src/actions/types.ts @@ -0,0 +1,25 @@ +import { ContractEvent } from '~types'; + +interface BaseActionMatcher { + handler: (events: ContractEvent[]) => Promise; +} + +interface SimpleActionMatcher extends BaseActionMatcher { + eventSignatures: string[]; +} + +interface FunctionActionMatcher extends BaseActionMatcher { + matcherFn: (events: ContractEvent[]) => boolean; +} + +export type ActionMatcher = SimpleActionMatcher | FunctionActionMatcher; + +export type ActionHandler = (events: ContractEvent[]) => Promise; + +export const isSimpleActionMatcher = ( + matcher: ActionMatcher, +): matcher is SimpleActionMatcher => 'eventSignatures' in matcher; + +export const isFunctionActionMatcher = ( + matcher: ActionMatcher, +): matcher is FunctionActionMatcher => 'matcherFn' in matcher; diff --git a/src/blockProcessor.ts b/src/blockProcessor.ts index 13fe6d73b..700c9ef67 100644 --- a/src/blockProcessor.ts +++ b/src/blockProcessor.ts @@ -11,6 +11,14 @@ import { setLastBlockNumber, } from '~utils'; import { BLOCK_PAGING_SIZE } from '~constants'; +import { ContractEvent } from '~types'; +import { isEqual } from 'lodash'; +import { + ActionMatcher, + isFunctionActionMatcher, + isSimpleActionMatcher, +} from '~actions/types'; +import { actionMatchers } from '~actions/actionMatchers'; let isProcessing = false; const blockLogs = new Map(); @@ -176,6 +184,8 @@ export const processNextBlock = async (): Promise => { } } + const blockEvents = []; + for (const log of logs) { // Find listeners that match the log const listeners = getMatchingListeners(log.topics, log.address); @@ -201,11 +211,87 @@ export const processNextBlock = async (): Promise => { continue; } + blockEvents.push(event); + // Call the handler in a blocking way to ensure events get processed sequentially - await listener.handler(event, listener); + await listener.handler?.(event, listener); } } + console.log({ blockEvents }); + + const hasMatch = ( + actionMatcher: ActionMatcher, + potentialMatch: ContractEvent[], + ): boolean => { + if (isSimpleActionMatcher(actionMatcher)) { + if (actionMatcher.eventSignatures.length !== potentialMatch.length) { + return false; + } + + return isEqual( + actionMatcher.eventSignatures, + potentialMatch.map((event) => event.signature), + ); + } + + if (isFunctionActionMatcher(actionMatcher)) { + return actionMatcher.matcherFn(potentialMatch); + } + + return false; + }; + + interface ActionMatch { + events: ContractEvent[]; + matcher: ActionMatcher; + } + + const matches: ActionMatch[] = []; + + let startIndex = 0; + let endIndex = blockEvents.length; + + while (startIndex < blockEvents.length) { + const potentialMatch = blockEvents.slice(startIndex, endIndex); + + console.log({ + potentialMatch: potentialMatch.map((event) => event.signature), + }); + + let matchFound = false; + for (const actionMatcher of actionMatchers) { + if (hasMatch(actionMatcher, potentialMatch)) { + matches.push({ + events: potentialMatch, + matcher: actionMatcher, + }); + // Move startIndex past the matched events + startIndex += potentialMatch.length; + endIndex = blockEvents.length; + matchFound = true; + break; // Exit the actionMatcher loop since we found a match + } + } + + // If no match was found, adjust the window + if (!matchFound) { + if (endIndex > startIndex + 1) { + endIndex--; + } else { + startIndex++; + endIndex = blockEvents.length; + } + } + } + + console.log({ matches }); + + // process the matches + for (const match of matches) { + await match.matcher.handler(match.events); + } + verbose('processed block', currentBlockNumber); lastBlockNumber = currentBlockNumber; diff --git a/src/eventListeners/colony.ts b/src/eventListeners/colony.ts index 8fa293120..8a9b380f4 100644 --- a/src/eventListeners/colony.ts +++ b/src/eventListeners/colony.ts @@ -26,7 +26,6 @@ import { handleEditColonyAction, handleEditDomainAction, handleEmitDomainReputationAction, - handleExpenditureAdded, handleExpenditureCancelled, handleExpenditureClaimDelaySet, handleExpenditureFinalized, @@ -40,7 +39,6 @@ import { handleExpenditureTransferred, handleMakeAbitraryTransactionAction, handleManagePermissionsAction, - handleMintTokensAction, handleMoveFundsAction, handleReputationMiningCycleComplete, handleSetTokenAuthority, @@ -52,7 +50,7 @@ import setTokenAuthority from '~handlers/tokens/setTokenAuthority'; const addColonyEventListener = ( eventSignature: ContractEventsSignatures, address: string, - handler: EventHandler, + handler?: EventHandler, ): void => { addEventListener({ type: EventListenerType.Colony, @@ -120,7 +118,6 @@ export const setupListenersForColony = ( const colonyEventHandlers = { [ContractEventsSignatures.ColonyFundsClaimed]: handleColonyFundsClaimed, [ContractEventsSignatures.ColonyUpgraded]: handleColonyUpgradeAction, - [ContractEventsSignatures.TokensMinted]: handleMintTokensAction, [ContractEventsSignatures.DomainAdded]: handleCreateDomainAction, [ContractEventsSignatures.DomainMetadata]: handleEditDomainAction, [ContractEventsSignatures.TokenUnlocked]: handleTokenUnlockedAction, @@ -134,7 +131,6 @@ export const setupListenersForColony = ( [ContractEventsSignatures.ColonyRoleSet_OLD]: handleManagePermissionsAction, [ContractEventsSignatures.ExpenditureGlobalClaimDelaySet]: handleExpenditureGlobalClaimDelaySet, - [ContractEventsSignatures.ExpenditureAdded]: handleExpenditureAdded, [ContractEventsSignatures.ExpenditureRecipientSet]: handleExpenditureRecipientSet, [ContractEventsSignatures.ExpenditurePayoutSet]: handleExpenditurePayoutSet, @@ -165,6 +161,12 @@ export const setupListenersForColony = ( ), ); + addColonyEventListener(ContractEventsSignatures.TokensMinted, colonyAddress); + addColonyEventListener( + ContractEventsSignatures.ExpenditureAdded, + colonyAddress, + ); + /* * @NOTE Setup both token event listners * diff --git a/src/eventListeners/extension/index.ts b/src/eventListeners/extension/index.ts index 2cde66df5..70efcec53 100644 --- a/src/eventListeners/extension/index.ts +++ b/src/eventListeners/extension/index.ts @@ -42,7 +42,7 @@ export const addExtensionEventListener = ( extensionId: Extension, extensionAddress: string, colonyAddress: string, - handler: EventHandler, + handler?: EventHandler, ): void => { addEventListener({ type: EventListenerType.Extension, diff --git a/src/eventListeners/extension/oneTxPayment.ts b/src/eventListeners/extension/oneTxPayment.ts index edc93cb32..b938c3a89 100644 --- a/src/eventListeners/extension/oneTxPayment.ts +++ b/src/eventListeners/extension/oneTxPayment.ts @@ -3,7 +3,6 @@ import { output } from '~utils'; import { ContractEventsSignatures } from '~types'; import { addExtensionEventListener, fetchExistingExtensions } from '.'; -import { handleOneTxPaymentAction } from '~handlers'; export const setupListenerForOneTxPayment = async ( extensionAddress: string, @@ -14,7 +13,6 @@ export const setupListenerForOneTxPayment = async ( Extension.OneTxPayment, extensionAddress, colonyAddress, - handleOneTxPaymentAction, ); }; diff --git a/src/eventListeners/types.ts b/src/eventListeners/types.ts index b42416fe2..c62fa10e2 100644 --- a/src/eventListeners/types.ts +++ b/src/eventListeners/types.ts @@ -4,7 +4,7 @@ export interface BaseEventListener { type: EventListenerType; eventSignature: ContractEventsSignatures; topics: Array; - handler: EventHandler; + handler?: EventHandler; address?: string; } @@ -43,7 +43,6 @@ export interface ExtensionEventListener extends BaseEventListener { address: string; colonyAddress: string; extensionHash: string; - handler: EventHandler; } export type EventListener = diff --git a/src/types/actions.ts b/src/types/actions.ts deleted file mode 100644 index d8ec9c374..000000000 --- a/src/types/actions.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ContractEvent } from './events'; - -export type ColonyActionHandler = (event: ContractEvent) => Promise; diff --git a/src/types/index.ts b/src/types/index.ts index fae0484f7..9d55d2f45 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,3 @@ export * from './events'; -export * from './actions'; export * from './methods'; export * from './motions';