Skip to content

Commit

Permalink
Introduce poap validity checker
Browse files Browse the repository at this point in the history
  • Loading branch information
svemat01 committed Nov 15, 2024
1 parent cb1453e commit 13d7bfa
Show file tree
Hide file tree
Showing 11 changed files with 295 additions and 4 deletions.
Binary file modified backend/bun.lockb
Binary file not shown.
12 changes: 12 additions & 0 deletions backend/drizzle/0001_condemned_lilith.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_links` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`url` text NOT NULL,
`used` integer DEFAULT false NOT NULL,
`claimed` integer DEFAULT false NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_links`("id", "url", "used", "claimed") SELECT "id", "url", "used", "claimed" FROM `links`;--> statement-breakpoint
DROP TABLE `links`;--> statement-breakpoint
ALTER TABLE `__new_links` RENAME TO `links`;--> statement-breakpoint
PRAGMA foreign_keys=ON;
58 changes: 58 additions & 0 deletions backend/drizzle/meta/0001_snapshot.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"version": "6",
"dialect": "sqlite",
"id": "38267cf7-0850-41d1-8206-8c8278ec9898",
"prevId": "34a4f4db-562d-405f-bc7f-0ed2208242c1",
"tables": {
"links": {
"name": "links",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"used": {
"name": "used",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"claimed": {
"name": "claimed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
7 changes: 7 additions & 0 deletions backend/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
"when": 1731651198038,
"tag": "0000_supreme_gabe_jones",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1731666614851,
"tag": "0001_condemned_lilith",
"breakpoints": true
}
]
}
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"drizzle-orm": "^0.36.2",
"envalid": "^8.0.0",
"hono": "^4.6.10",
"neverthrow": "^8.1.1",
"pino": "^9.5.0",
"zod": "^3.23.8"
},
Expand Down
138 changes: 138 additions & 0 deletions backend/src/checker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { and, eq } from "drizzle-orm";
import { db } from "./db";
import { links, links as linksTable } from "./db/schema";
import { batcher } from "./util/batch";
import { isClaimed, type ClaimResponse } from "./util/poap";
import { logger as baseLogger } from "./util/logger";
import { err, fromThrowable, ok, type Result } from "neverthrow";

const extractId = (link: string): Result<string, Error> => {
// error if not matching /mint/ID
const url = fromThrowable(
() => new URL(link),
(cause) => new Error("Failed to parse link", { cause })
)();

if (url.isErr()) {
return err(url.error);
}

const match = url.value.pathname.match(/^\/mint\/(.+)$/);
if (!match || match.length < 2) {
return err(new Error(`Invalid link: ${link}`));
}

// Return matching group 1
return ok(match[1]);
};

export const checkClaims = async (
logger: typeof baseLogger = baseLogger,
emitter?: (message: string) => void
) => {
const emit = emitter ?? (() => {});

logger.info("Checking POAP claims");
emit("Checking POAP claims");

const usedLinks = await db.query.links.findMany({
where: eq(linksTable.claimed, false),
});

const batches = batcher(usedLinks, 10);
const results: {
linkId: number;
poapId: string | null;
result: Result<ClaimResponse, Error>;
}[] = [];

let i = 0;
for (const batch of batches) {
i++;
logger.trace(
{
batch: i,
total: batches.length,
},
`Checking batch`
);
emit(`Checking batch ${i} of ${batches.length}`);

const batchResults = await Promise.all(
batch.map(async (link) => {
const poapId = extractId(link.url);
if (poapId.isErr()) {
return {
linkId: link.id,
poapId: null,
result: err(poapId.error),
};
}
const result = await isClaimed(poapId.value);
return {
linkId: link.id,
poapId: poapId.value,
result,
};
})
);

results.push(...batchResults);

logger.trace(
{
batch: i,
total: batches.length,
failed: batchResults.filter(({ result }) => result.isErr())
.length,
},
`Batch completed`
);
emit(`Batch ${i} completed`);
}

logger.info(
{
total: results.length,
failed: results.filter(({ result }) => result.isErr()).length,
},
"All batches completed"
);
emit(`All batches completed`);

let unclaimed: number[] = [];

for (const { linkId, poapId, result } of results) {
if (result.isErr()) {
logger.error(
{ linkId, poapId, error: `${result.error}` },
"Failed to check claim"
);
emit(`${linkId}: Failed to check claim: ${result.error}`);
} else {
if (result.value.claimed) {
logger.trace({ linkId, poapId }, "POAP has been Claimed");
emit(`${linkId}: POAP has been Claimed`);

await db
.update(linksTable)
.set({ claimed: true })
.where(eq(linksTable.id, linkId));
} else {
logger.trace({ linkId, poapId }, "POAP has not been Claimed");
unclaimed.push(linkId);

await db
.update(linksTable)
.set({ claimed: false, used: false })
.where(eq(linksTable.id, linkId));
}
}
}

emit(`Unclaimed POAPs (${unclaimed.length}): ${unclaimed.join(", ")}`);

emit(`All POAPs checked`);

return results;
};
3 changes: 2 additions & 1 deletion backend/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const links = sqliteTable("links", {
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
url: text("url").notNull(),
used: integer("used", { mode: "boolean" }).default(false),
used: integer("used", { mode: "boolean" }).default(false).notNull(),
claimed: integer("claimed", { mode: "boolean" }).default(false).notNull(),
});
12 changes: 12 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
requestLoggerMiddleware,
} from "./util/hono";
import { routesRouter } from "./routes/_";
import { checkClaims } from "./checker";
import { logger } from "./util/logger";

const app = new Hono()
.use(cors())
Expand All @@ -20,4 +22,14 @@ const app = new Hono()
.route("/", routesRouter)
.onError(honoErrorHandler);

setInterval(async () => {
const _logger = logger.child({
type: "interval",
});

await checkClaims(_logger).catch((e) => {
_logger.error(e, "Failed to check POAP claims");
});
}, 1000 * 60 * 5);

export default app;
26 changes: 23 additions & 3 deletions backend/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { links } from "../db/schema";
import { eq } from "drizzle-orm";
import { bearerAuth } from "hono/bearer-auth";
import { environment } from "../util/environment";
import { checkClaims } from "../checker";
import { streamSSE } from "hono/streaming";

// Sub router at /admin
export const adminRouter = createBaseApp()
Expand All @@ -19,7 +21,7 @@ export const adminRouter = createBaseApp()
zValidator(
"param",
z.object({
id: z.number(),
id: z.coerce.number(),
})
),
async (c) => {
Expand Down Expand Up @@ -96,7 +98,7 @@ export const adminRouter = createBaseApp()
zValidator(
"param",
z.object({
id: z.number(),
id: z.coerce.number(),
})
),
zValidator(
Expand Down Expand Up @@ -134,7 +136,7 @@ export const adminRouter = createBaseApp()
zValidator(
"param",
z.object({
id: z.number(),
id: z.coerce.number(),
})
),
async (c) => {
Expand All @@ -154,4 +156,22 @@ export const adminRouter = createBaseApp()

return c.json({ success: true });
}
)
.get("/check_poaps", (c) =>
streamSSE(c, async (stream) => {
const checker = checkClaims(c.var.logger, (message) => {
stream.writeSSE({
data: message,
event: "message",
});
});

await checker;
stream.writeSSE({
data: "done",
event: "done",
});
await stream.sleep(200);
await stream.close();
})
);
7 changes: 7 additions & 0 deletions backend/src/util/batch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const batcher = <T>(items: T[], batchSize: number) => {
const batches = [];
for (let i = 0; i < items.length; i += batchSize) {
batches.push(items.slice(i, i + batchSize));
}
return batches;
};
35 changes: 35 additions & 0 deletions backend/src/util/poap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { errAsync, fromPromise, type ResultAsync } from "neverthrow";

export type ClaimResponse = {
claimed: boolean;
};

export const isClaimed = (id: string): ResultAsync<ClaimResponse, Error> => {
return fromPromise(
fetch(`https://frontend.poap.tech/actions/claim-qr?qr_hash=${id}`, {
headers: {
origin: "https://collectors.poap.xyz",
},
}),
(e) =>
new Error(`Failed to fetch POAP claim status`, {
cause: e,
})
).andThen((response) => {
if (!response.ok) {
return errAsync(
new Error(
`Failed to fetch POAP claim status: ${response.statusText}`
)
);
}

return fromPromise(
response.json() as Promise<ClaimResponse>,
(e) =>
new Error(`Failed to parse POAP claim status`, {
cause: e,
})
);
});
};

0 comments on commit 13d7bfa

Please sign in to comment.