From 1137a040c4ab37362dae72c670258e2d942428ff Mon Sep 17 00:00:00 2001 From: Daryl Collins Date: Tue, 2 Apr 2024 23:24:39 +0100 Subject: [PATCH] feat(lit-task-auto-top-up): LIT-2791 - Limit auto-top-up service to limit number of un-expired NFTs per recipient - Only top-up if a recipient will have 0 un-expired tokens tomorrow --- .../src/Classes/TaskHandler.ts | 76 +++++++++++++++++-- .../lit-task-auto-top-up/src/taskHelpers.ts | 48 +++++++++--- .../lit-task-auto-top-up/src/types/enums.ts | 4 + .../lit-task-auto-top-up/src/types/types.ts | 13 +++- 4 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 packages/lit-task-auto-top-up/src/types/enums.ts diff --git a/packages/lit-task-auto-top-up/src/Classes/TaskHandler.ts b/packages/lit-task-auto-top-up/src/Classes/TaskHandler.ts index 5804b2e..3750bd2 100644 --- a/packages/lit-task-auto-top-up/src/Classes/TaskHandler.ts +++ b/packages/lit-task-auto-top-up/src/Classes/TaskHandler.ts @@ -1,13 +1,16 @@ import { Job } from '@hokify/agenda'; import awaity from 'awaity'; // Awaity is a cjs package, breaks `import` with named imports in ESM import { ConsolaInstance } from 'consola'; +import date from 'date-and-time'; import VError from 'verror'; import { mintCapacityCreditNFT } from '../actions/mintCapacityCreditNFT'; import { transferCapacityTokenNFT } from '../actions/transferCapacityTokenNFT'; import { toErrorWithMessage } from '../errors'; +import { getLitContractsInstance } from '../singletons/getLitContracts'; import { getRecipientList } from '../singletons/getRecipientList'; import { tryTouchTask, printTaskResultsAndFailures } from '../taskHelpers'; +import { TaskResultEnum } from '../types/enums'; import { Config, EnvConfig, RecipientDetail, TaskResult } from '../types/types'; const { mapSeries } = awaity; @@ -29,11 +32,74 @@ export class TaskHandler { recipientDetail: RecipientDetail; }): Promise { const { recipientAddress } = recipientDetail; + const { noUnexpiredTokensTomorrow, unexpiredTokens } = await this.getExistingTokenDetails({ + recipientAddress, + }); - const capacityTokenIdStr = await mintCapacityCreditNFT({ recipientDetail }); - await transferCapacityTokenNFT({ capacityTokenIdStr, recipientAddress }); + if (noUnexpiredTokensTomorrow) { + const capacityTokenIdStr = await mintCapacityCreditNFT({ recipientDetail }); + await transferCapacityTokenNFT({ capacityTokenIdStr, recipientAddress }); - return { capacityTokenIdStr, ...recipientDetail }; + return { capacityTokenIdStr, result: TaskResultEnum.minted, ...recipientDetail }; + } + + // If we got here, there should be some `unexpiredTokens` to log for clarity later. + return { + ...recipientDetail, + unexpiredTokens, + result: TaskResultEnum.skipped, + }; + } + + private async getExistingTokenDetails({ recipientAddress }: { recipientAddress: string }) { + const tomorrow = date.addDays(new Date(), 1); + const litContracts = await getLitContractsInstance(); + + // :sad_panda:, `getTokensByOwnerAddress()` returns :( + const existingTokens: { + URI: { description: string; image_data: string; name: string }; + capacity: { + expiresAt: { formatted: string; timestamp: number }; + requestsPerMillisecond: number; + }; + isExpired: boolean; + tokenId: number; + }[] = + await litContracts.rateLimitNftContractUtils.read.getTokensByOwnerAddress(recipientAddress); + + // Only mint a new token if the recipient... + // 1. Has no NFTs at all + // 2. All unexpired NFTs they have will expire tomorrow + // 3. All of their NFTs are already expired + const noUnexpiredTokensTomorrow = existingTokens.every((token) => { + // NOTE: `every()` on an empty array === true :) + const { + capacity: { + expiresAt: { timestamp }, + }, + isExpired, + } = token; + if (isExpired) { + return true; + } + return date.isSameDay(new Date(timestamp), tomorrow); + }); + return { + noUnexpiredTokensTomorrow, + unexpiredTokens: existingTokens + .filter(({ isExpired }) => !isExpired) + .map( + ({ + capacity: { + expiresAt: { formatted }, + }, + tokenId, + }) => ({ + tokenId, + expiresAt: formatted, + }) + ), + }; } async handleTask(task: Job) { @@ -50,7 +116,7 @@ export class TaskHandler { this.handleRecipient({ recipientDetail }), ]); - this.logger.log(`Finished top-up for ${recipientDetail.recipientAddress}`); + this.logger.log(`Finished processing ${recipientDetail.recipientAddress}`); tryTouchTask(task).then(() => true); // Fire-and-forget; touching job is not critical return settledResult; @@ -60,7 +126,7 @@ export class TaskHandler { printTaskResultsAndFailures({ results, logger: this.logger }); } catch (e) { const err = toErrorWithMessage(e); - this.logger.error('CRITICAL ERROR', JSON.stringify(VError.info(err), null, 2)); + this.logger.error('CRITICAL ERROR', e, JSON.stringify(VError.info(err), null, 2)); // Re-throw so the job is retried by the task worker throw err; diff --git a/packages/lit-task-auto-top-up/src/taskHelpers.ts b/packages/lit-task-auto-top-up/src/taskHelpers.ts index 3970c39..b306ed9 100644 --- a/packages/lit-task-auto-top-up/src/taskHelpers.ts +++ b/packages/lit-task-auto-top-up/src/taskHelpers.ts @@ -3,8 +3,21 @@ import { ConsolaInstance } from 'consola'; import _ from 'lodash'; import VError, { MultiError } from 'verror'; +import { TaskResultEnum } from './types/enums'; import { TaskResult } from './types/types'; +// This is so fly like superman, I gotta credit mmmmveggies. Extract<> is pretty cool. +// https://stackoverflow.com/a/63831756 +function hasProp(k: K, v: V) { + // All candidate types might have key `K` of any type + type Candidate = Partial> | null | undefined; + + // All matching subtypes of T must have key `K` equal value `V` + type Match = Extract>; + + return (obj: T): obj is Match => obj?.[k] === v; +} + export function printTaskResultsAndFailures({ logger, results, @@ -12,19 +25,32 @@ export function printTaskResultsAndFailures({ logger: ConsolaInstance; results: PromiseSettledResult[]; }) { - const fulfilled = results.filter( - (r): r is PromiseFulfilledResult => r.status === 'fulfilled' - ); - const rejected = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected'); + const fulfilled = results.filter(hasProp('status', 'fulfilled')); + const rejected = results.filter(hasProp('status', 'rejected')); const errors = rejected.map(({ reason }: { reason: Error }) => reason); - const successes = fulfilled.map(({ value }) => value); + const mints = fulfilled + .map(({ value }) => value) + .filter(hasProp('result', TaskResultEnum.minted)); + const skips = fulfilled + .map(({ value }) => value) + .filter(hasProp('result', TaskResultEnum.skipped)); - logger.log(`Succeeded topping off ${successes.length} recipients`); - _.forEach(successes, (r) => { - const { recipientAddress, ...rest } = r; - logger.log(`Minted for ${recipientAddress}`, rest); - }); + if (skips.length > 0) { + logger.log(`Skipped topping off ${skips.length} recipients`); + _.forEach(skips, (r) => { + const { recipientAddress, ...rest } = r; + logger.log(`Skipped ${recipientAddress}`, rest); + }); + } + + if (mints.length > 0) { + logger.log(`Succeeded topping off ${mints.length} recipients`); + _.forEach(mints, (r) => { + const { recipientAddress, ...rest } = r; + logger.log(`Minted for ${recipientAddress}`, rest); + }); + } if (errors.length > 0) { logger.log(`Failed to top off ${errors.length} recipients`); @@ -37,7 +63,7 @@ export function printTaskResultsAndFailures({ throw VError.errorFromList(errors) as MultiError; } } else { - logger.log('All recipients were topped off successfully.'); + logger.log('All recipients were topped off successfully or skipped.'); } } diff --git a/packages/lit-task-auto-top-up/src/types/enums.ts b/packages/lit-task-auto-top-up/src/types/enums.ts new file mode 100644 index 0000000..0879624 --- /dev/null +++ b/packages/lit-task-auto-top-up/src/types/enums.ts @@ -0,0 +1,4 @@ +export enum TaskResultEnum { + minted = 'minted', + skipped = 'skipped', +} diff --git a/packages/lit-task-auto-top-up/src/types/types.ts b/packages/lit-task-auto-top-up/src/types/types.ts index d97ba75..aceb4b2 100644 --- a/packages/lit-task-auto-top-up/src/types/types.ts +++ b/packages/lit-task-auto-top-up/src/types/types.ts @@ -1,6 +1,7 @@ import { ConsolaInstance } from 'consola'; import { z } from 'zod'; +import { TaskResultEnum } from './enums'; import { recipientDetailSchema, envConfigSchema } from './schemas'; export type EnvConfig = z.infer; @@ -8,6 +9,16 @@ export type Config = { envConfig: EnvConfig; logger: ConsolaInstance }; export type RecipientDetail = z.infer; -export interface TaskResult extends RecipientDetail { +export type TaskResult = TaskResultMinted | TaskResultSkipped; +export interface TaskResultMinted extends RecipientDetail { capacityTokenIdStr: string; + result: TaskResultEnum.minted; +} + +export interface TaskResultSkipped extends RecipientDetail { + result: TaskResultEnum.skipped; + unexpiredTokens: { + expiresAt: string; + tokenId: number; + }[]; }