Skip to content

Commit

Permalink
feat(lit-task-auto-top-up): LIT-2791 - Limit auto-top-up service to l…
Browse files Browse the repository at this point in the history
…imit number of un-expired NFTs per recipient

- Only top-up if a recipient will have 0 un-expired tokens tomorrow
  • Loading branch information
MaximusHaximus committed Apr 2, 2024
1 parent eff3ae9 commit 1137a04
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 17 deletions.
76 changes: 71 additions & 5 deletions packages/lit-task-auto-top-up/src/Classes/TaskHandler.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -29,11 +32,74 @@ export class TaskHandler {
recipientDetail: RecipientDetail;
}): Promise<TaskResult> {
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 <any> :(
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) {
Expand All @@ -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;
Expand All @@ -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;
Expand Down
48 changes: 37 additions & 11 deletions packages/lit-task-auto-top-up/src/taskHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,54 @@ 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 extends PropertyKey, V extends string | number | boolean>(k: K, v: V) {
// All candidate types might have key `K` of any type
type Candidate = Partial<Record<K, any>> | null | undefined;

// All matching subtypes of T must have key `K` equal value `V`
type Match<T extends Candidate> = Extract<T, Record<K, V>>;

return <T extends Candidate>(obj: T): obj is Match<T> => obj?.[k] === v;
}

export function printTaskResultsAndFailures({
logger,
results,
}: {
logger: ConsolaInstance;
results: PromiseSettledResult<TaskResult>[];
}) {
const fulfilled = results.filter(
(r): r is PromiseFulfilledResult<TaskResult> => 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`);
Expand All @@ -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.');
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/lit-task-auto-top-up/src/types/enums.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum TaskResultEnum {
minted = 'minted',
skipped = 'skipped',
}
13 changes: 12 additions & 1 deletion packages/lit-task-auto-top-up/src/types/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import { ConsolaInstance } from 'consola';
import { z } from 'zod';

import { TaskResultEnum } from './enums';
import { recipientDetailSchema, envConfigSchema } from './schemas';

export type EnvConfig = z.infer<typeof envConfigSchema>;
export type Config = { envConfig: EnvConfig; logger: ConsolaInstance };

export type RecipientDetail = z.infer<typeof recipientDetailSchema>;

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;
}[];
}

0 comments on commit 1137a04

Please sign in to comment.