From 6e742919ea3d65f5e69b52488868b393486e27b3 Mon Sep 17 00:00:00 2001 From: Eiman Date: Fri, 30 Jan 2026 11:06:42 -0600 Subject: [PATCH 1/7] feat: add transaction backfill fallback for queue ID lookups - Add ENABLE_TX_BACKFILL_FALLBACK env var (default: false) - Add backfill methods to TransactionDB (getBackfillHash, setBackfill, bulkSetBackfill) - Add fallback lookup in /transaction/logs endpoint - Add POST /admin/backfill endpoint for loading backfill data When enabled, the /transaction/logs endpoint will check a fallback Redis table (backfill:) when the primary transaction cache misses. This allows recovering transaction logs for queue IDs that were pruned from the main cache. Co-Authored-By: Claude Opus 4.5 --- src/server/routes/admin/backfill.ts | 57 +++++++++++++++++++ src/server/routes/index.ts | 2 + .../routes/transaction/blockchain/get-logs.ts | 10 ++++ src/shared/db/transactions/db.ts | 50 ++++++++++++++++ src/shared/utils/env.ts | 3 + 5 files changed, 122 insertions(+) create mode 100644 src/server/routes/admin/backfill.ts diff --git a/src/server/routes/admin/backfill.ts b/src/server/routes/admin/backfill.ts new file mode 100644 index 000000000..f0bd945c4 --- /dev/null +++ b/src/server/routes/admin/backfill.ts @@ -0,0 +1,57 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { TransactionDB } from "../../../shared/db/transactions/db"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; + +const requestBodySchema = Type.Object({ + entries: Type.Array( + Type.Object({ + queueId: Type.String({ description: "Queue ID (UUID)" }), + transactionHash: Type.String({ description: "Transaction hash (0x...)" }), + }), + { description: "Array of queueId to transactionHash mappings", maxItems: 10000 }, + ), +}); + +const responseBodySchema = Type.Object({ + result: Type.Object({ + inserted: Type.Integer({ description: "Number of entries inserted" }), + skipped: Type.Integer({ + description: "Number of entries skipped (already exist)", + }), + }), +}); + +export async function loadBackfillRoute(fastify: FastifyInstance) { + fastify.route<{ + Body: Static; + Reply: Static; + }>({ + method: "POST", + url: "/admin/backfill", + schema: { + summary: "Load backfill entries", + description: + "Load queueId to transactionHash mappings into the backfill table. Uses SETNX to never overwrite existing entries.", + tags: ["Admin"], + operationId: "loadBackfill", + body: requestBodySchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseBodySchema, + }, + hide: true, + }, + handler: async (request, reply) => { + const { entries } = request.body; + + const { inserted, skipped } = + await TransactionDB.bulkSetBackfill(entries); + + reply.status(StatusCodes.OK).send({ + result: { inserted, skipped }, + }); + }, + }); +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 1150458dc..b02cd0399 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,4 +1,5 @@ import type { FastifyInstance } from "fastify"; +import { loadBackfillRoute } from "./admin/backfill"; import { getNonceDetailsRoute } from "./admin/nonces"; import { getTransactionDetails } from "./admin/transaction"; import { createAccessToken } from "./auth/access-tokens/create"; @@ -297,4 +298,5 @@ export async function withRoutes(fastify: FastifyInstance) { // Admin await fastify.register(getTransactionDetails); await fastify.register(getNonceDetailsRoute); + await fastify.register(loadBackfillRoute); } diff --git a/src/server/routes/transaction/blockchain/get-logs.ts b/src/server/routes/transaction/blockchain/get-logs.ts index 7f4610018..9ab23dea4 100644 --- a/src/server/routes/transaction/blockchain/get-logs.ts +++ b/src/server/routes/transaction/blockchain/get-logs.ts @@ -15,6 +15,7 @@ import { resolveContractAbi } from "thirdweb/contract"; import type { TransactionReceipt } from "thirdweb/transaction"; import { TransactionDB } from "../../../../shared/db/transactions/db"; import { getChain } from "../../../../shared/utils/chain"; +import { env } from "../../../../shared/utils/env"; import { thirdwebClient } from "../../../../shared/utils/sdk"; import { createCustomError } from "../../../middleware/error"; import { AddressSchema, TransactionHashSchema } from "../../../schemas/address"; @@ -153,10 +154,19 @@ export async function getTransactionLogs(fastify: FastifyInstance) { // Get the transaction hash from the provided input. let hash: Hex | undefined; if (queueId) { + // Primary lookup const transaction = await TransactionDB.get(queueId); if (transaction?.status === "mined") { hash = transaction.transactionHash; } + + // Fallback to backfill table if enabled and not found + if (!hash && env.ENABLE_TX_BACKFILL_FALLBACK) { + const backfillHash = await TransactionDB.getBackfillHash(queueId); + if (backfillHash) { + hash = backfillHash as Hex; + } + } } else if (transactionHash) { hash = transactionHash as Hex; } diff --git a/src/shared/db/transactions/db.ts b/src/shared/db/transactions/db.ts index 3250e5cb2..4058e3cad 100644 --- a/src/shared/db/transactions/db.ts +++ b/src/shared/db/transactions/db.ts @@ -37,6 +37,7 @@ export class TransactionDB { private static minedTransactionsKey = "transaction:mined"; private static cancelledTransactionsKey = "transaction:cancelled"; private static erroredTransactionsKey = "transaction:errored"; + private static backfillKey = (queueId: string) => `backfill:${queueId}`; /** * Inserts or replaces a transaction details. @@ -208,6 +209,55 @@ export class TransactionDB { return numPruned; }; + + /** + * Gets transaction hash from backfill table. + */ + static getBackfillHash = async (queueId: string): Promise => { + return redis.get(this.backfillKey(queueId)); + }; + + /** + * Sets a backfill entry. Uses SETNX to never overwrite. + * @returns true if set, false if already exists + */ + static setBackfill = async ( + queueId: string, + transactionHash: string, + ): Promise => { + const result = await redis.setnx( + this.backfillKey(queueId), + transactionHash, + ); + return result === 1; + }; + + /** + * Bulk set backfill entries. + * @returns { inserted: number, skipped: number } + */ + static bulkSetBackfill = async ( + entries: Array<{ queueId: string; transactionHash: string }>, + ): Promise<{ inserted: number; skipped: number }> => { + let inserted = 0; + let skipped = 0; + + const pipeline = redis.pipeline(); + for (const { queueId, transactionHash } of entries) { + pipeline.setnx(this.backfillKey(queueId), transactionHash); + } + + const results = await pipeline.exec(); + for (const [err, result] of results ?? []) { + if (!err && result === 1) { + inserted++; + } else { + skipped++; + } + } + + return { inserted, skipped }; + }; } const toSeconds = (timestamp: Date) => timestamp.getTime() / 1000; diff --git a/src/shared/utils/env.ts b/src/shared/utils/env.ts index 3bbbe593d..758cdd7a3 100644 --- a/src/shared/utils/env.ts +++ b/src/shared/utils/env.ts @@ -98,6 +98,8 @@ export const env = createEnv({ SEND_WEBHOOK_QUEUE_CONCURRENCY: z.coerce.number().default(10), + ENABLE_TX_BACKFILL_FALLBACK: boolEnvSchema(false), + /** * Experimental env vars. These may be renamed or removed in future non-major releases. */ @@ -177,6 +179,7 @@ export const env = createEnv({ EXPERIMENTAL__RETRY_PREPARE_USEROP_ERRORS: process.env.EXPERIMENTAL__RETRY_PREPARE_USEROP_ERRORS, SEND_WEBHOOK_QUEUE_CONCURRENCY: process.env.SEND_WEBHOOK_QUEUE_CONCURRENCY, + ENABLE_TX_BACKFILL_FALLBACK: process.env.ENABLE_TX_BACKFILL_FALLBACK, }, onValidationError: (error: ZodError) => { console.error( From acb89e2e202077ebe8dbb9ce6497b98216f737b7 Mon Sep 17 00:00:00 2001 From: Eiman Date: Fri, 30 Jan 2026 11:16:51 -0600 Subject: [PATCH 2/7] feat: add DELETE /admin/backfill endpoint to clear backfill table Co-Authored-By: Claude Opus 4.5 --- src/server/routes/admin/backfill.ts | 51 +++++++++++++++++++++++++---- src/server/routes/index.ts | 3 +- src/shared/db/transactions/db.ts | 27 +++++++++++++++ 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/server/routes/admin/backfill.ts b/src/server/routes/admin/backfill.ts index f0bd945c4..4c5031edd 100644 --- a/src/server/routes/admin/backfill.ts +++ b/src/server/routes/admin/backfill.ts @@ -4,17 +4,20 @@ import { StatusCodes } from "http-status-codes"; import { TransactionDB } from "../../../shared/db/transactions/db"; import { standardResponseSchema } from "../../schemas/shared-api-schemas"; -const requestBodySchema = Type.Object({ +const loadRequestBodySchema = Type.Object({ entries: Type.Array( Type.Object({ queueId: Type.String({ description: "Queue ID (UUID)" }), transactionHash: Type.String({ description: "Transaction hash (0x...)" }), }), - { description: "Array of queueId to transactionHash mappings", maxItems: 10000 }, + { + description: "Array of queueId to transactionHash mappings", + maxItems: 10000, + }, ), }); -const responseBodySchema = Type.Object({ +const loadResponseBodySchema = Type.Object({ result: Type.Object({ inserted: Type.Integer({ description: "Number of entries inserted" }), skipped: Type.Integer({ @@ -23,10 +26,16 @@ const responseBodySchema = Type.Object({ }), }); +const clearResponseBodySchema = Type.Object({ + result: Type.Object({ + deleted: Type.Integer({ description: "Number of entries deleted" }), + }), +}); + export async function loadBackfillRoute(fastify: FastifyInstance) { fastify.route<{ - Body: Static; - Reply: Static; + Body: Static; + Reply: Static; }>({ method: "POST", url: "/admin/backfill", @@ -36,10 +45,10 @@ export async function loadBackfillRoute(fastify: FastifyInstance) { "Load queueId to transactionHash mappings into the backfill table. Uses SETNX to never overwrite existing entries.", tags: ["Admin"], operationId: "loadBackfill", - body: requestBodySchema, + body: loadRequestBodySchema, response: { ...standardResponseSchema, - [StatusCodes.OK]: responseBodySchema, + [StatusCodes.OK]: loadResponseBodySchema, }, hide: true, }, @@ -55,3 +64,31 @@ export async function loadBackfillRoute(fastify: FastifyInstance) { }, }); } + +export async function clearBackfillRoute(fastify: FastifyInstance) { + fastify.route<{ + Reply: Static; + }>({ + method: "DELETE", + url: "/admin/backfill", + schema: { + summary: "Clear backfill table", + description: + "Delete all entries from the backfill table. This action cannot be undone.", + tags: ["Admin"], + operationId: "clearBackfill", + response: { + ...standardResponseSchema, + [StatusCodes.OK]: clearResponseBodySchema, + }, + hide: true, + }, + handler: async (_request, reply) => { + const deleted = await TransactionDB.clearBackfill(); + + reply.status(StatusCodes.OK).send({ + result: { deleted }, + }); + }, + }); +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index b02cd0399..4483b70f0 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { loadBackfillRoute } from "./admin/backfill"; +import { clearBackfillRoute, loadBackfillRoute } from "./admin/backfill"; import { getNonceDetailsRoute } from "./admin/nonces"; import { getTransactionDetails } from "./admin/transaction"; import { createAccessToken } from "./auth/access-tokens/create"; @@ -299,4 +299,5 @@ export async function withRoutes(fastify: FastifyInstance) { await fastify.register(getTransactionDetails); await fastify.register(getNonceDetailsRoute); await fastify.register(loadBackfillRoute); + await fastify.register(clearBackfillRoute); } diff --git a/src/shared/db/transactions/db.ts b/src/shared/db/transactions/db.ts index 4058e3cad..9eed2fc58 100644 --- a/src/shared/db/transactions/db.ts +++ b/src/shared/db/transactions/db.ts @@ -258,6 +258,33 @@ export class TransactionDB { return { inserted, skipped }; }; + + /** + * Clears all backfill entries. + * @returns number - The number of entries deleted. + */ + static clearBackfill = async (): Promise => { + let totalDeleted = 0; + let cursor = "0"; + + do { + const [nextCursor, keys] = await redis.scan( + cursor, + "MATCH", + "backfill:*", + "COUNT", + 1000, + ); + cursor = nextCursor; + + if (keys.length > 0) { + const deleted = await redis.unlink(...keys); + totalDeleted += deleted; + } + } while (cursor !== "0"); + + return totalDeleted; + }; } const toSeconds = (timestamp: Date) => timestamp.getTime() / 1000; From 53d55321b0793dc5e1c84ea718ebbf859822d0ef Mon Sep 17 00:00:00 2001 From: Eiman Date: Fri, 30 Jan 2026 13:17:47 -0600 Subject: [PATCH 3/7] docs: add comments explaining AMEX backfill logic Co-Authored-By: Claude Opus 4.5 --- src/server/routes/admin/backfill.ts | 12 ++++++++++++ src/server/routes/transaction/blockchain/get-logs.ts | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/src/server/routes/admin/backfill.ts b/src/server/routes/admin/backfill.ts index 4c5031edd..afb229147 100644 --- a/src/server/routes/admin/backfill.ts +++ b/src/server/routes/admin/backfill.ts @@ -4,6 +4,18 @@ import { StatusCodes } from "http-status-codes"; import { TransactionDB } from "../../../shared/db/transactions/db"; import { standardResponseSchema } from "../../schemas/shared-api-schemas"; +// SPECIAL LOGIC FOR AMEX +// added two admin routes to backfill tx hashes to the backfill table +// loadBackfillRoute and clearBackfillRoute +// loadBackfillRoute is used to load tx hashes to the backfill table +// clearBackfillRoute is used to clear the backfill table +// these routes are used by the AMEX script to backfill tx hashes to the backfill table +// see https://github.com/thirdweb-dev/solutions-customer-scripts/blob/main/amex/scripts/load-backfill-via-api.ts +// loadBackfillRoute is used to load tx hashes to the backfill table +// clearBackfillRoute is used to clear the backfill table +// these routes are used by the AMEX script to backfill tx hashes to the backfill table +// see https://github.com/thirdweb-dev/solutions-customer-scripts/blob/main/amex/scripts/load-backfill-via-api.ts + const loadRequestBodySchema = Type.Object({ entries: Type.Array( Type.Object({ diff --git a/src/server/routes/transaction/blockchain/get-logs.ts b/src/server/routes/transaction/blockchain/get-logs.ts index 9ab23dea4..5b1e3e81f 100644 --- a/src/server/routes/transaction/blockchain/get-logs.ts +++ b/src/server/routes/transaction/blockchain/get-logs.ts @@ -160,6 +160,10 @@ export async function getTransactionLogs(fastify: FastifyInstance) { hash = transaction.transactionHash; } + // SPECIAL LOGIC FOR AMEX + // AMEX uses this endpoint to get logs for transactions they didn't receive webhooks for + // the queue ID's were cleaned out of REDIS so we backfilled tx hashes to this backfill table + // see https://github.com/thirdweb-dev/solutions-customer-scripts/blob/main/amex/scripts/load-backfill-via-api.ts // Fallback to backfill table if enabled and not found if (!hash && env.ENABLE_TX_BACKFILL_FALLBACK) { const backfillHash = await TransactionDB.getBackfillHash(queueId); From a8a64bdbc0df4576f86cbda2929f63dd3a88c058 Mon Sep 17 00:00:00 2001 From: Eiman Date: Fri, 30 Jan 2026 13:32:02 -0600 Subject: [PATCH 4/7] fix: validate backfill hash format before use Add isHex validation to prevent unexpected behavior if malformed data exists in the backfill table. Co-Authored-By: Claude Opus 4.5 --- src/server/routes/transaction/blockchain/get-logs.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server/routes/transaction/blockchain/get-logs.ts b/src/server/routes/transaction/blockchain/get-logs.ts index 5b1e3e81f..30e416041 100644 --- a/src/server/routes/transaction/blockchain/get-logs.ts +++ b/src/server/routes/transaction/blockchain/get-logs.ts @@ -8,6 +8,7 @@ import { eth_getTransactionReceipt, getContract, getRpcClient, + isHex, parseEventLogs, prepareEvent, } from "thirdweb"; @@ -167,7 +168,7 @@ export async function getTransactionLogs(fastify: FastifyInstance) { // Fallback to backfill table if enabled and not found if (!hash && env.ENABLE_TX_BACKFILL_FALLBACK) { const backfillHash = await TransactionDB.getBackfillHash(queueId); - if (backfillHash) { + if (backfillHash && isHex(backfillHash)) { hash = backfillHash as Hex; } } From 2cd06ebde8c8823be2b2daa2f0f4f0ca614a0ebb Mon Sep 17 00:00:00 2001 From: Eiman Date: Mon, 2 Feb 2026 09:13:08 -0600 Subject: [PATCH 5/7] feat: extend backfill fallback to support /transaction/status endpoint - Add BackfillEntry interface with status field ("mined" | "errored") - Update TransactionDB to store/retrieve JSON format for backfill entries - Add backfill fallback lookup to /transaction/status routes - Update /transaction/logs to use new getBackfill method - Update admin backfill schema to accept status field - Maintain backwards compatibility for plain string tx hash entries Co-Authored-By: Claude Opus 4.5 --- src/server/routes/admin/backfill.ts | 9 +- .../routes/transaction/blockchain/get-logs.ts | 6 +- src/server/routes/transaction/status.ts | 92 +++++++++++++++++++ src/shared/db/transactions/db.ts | 41 ++++++++- 4 files changed, 138 insertions(+), 10 deletions(-) diff --git a/src/server/routes/admin/backfill.ts b/src/server/routes/admin/backfill.ts index afb229147..e8201c4b3 100644 --- a/src/server/routes/admin/backfill.ts +++ b/src/server/routes/admin/backfill.ts @@ -20,10 +20,15 @@ const loadRequestBodySchema = Type.Object({ entries: Type.Array( Type.Object({ queueId: Type.String({ description: "Queue ID (UUID)" }), - transactionHash: Type.String({ description: "Transaction hash (0x...)" }), + status: Type.Union([Type.Literal("mined"), Type.Literal("errored")], { + description: "Transaction status: 'mined' for successful transactions, 'errored' for failed ones", + }), + transactionHash: Type.Optional( + Type.String({ description: "Transaction hash (0x...). Required for mined transactions." }), + ), }), { - description: "Array of queueId to transactionHash mappings", + description: "Array of queueId to status/transactionHash mappings", maxItems: 10000, }, ), diff --git a/src/server/routes/transaction/blockchain/get-logs.ts b/src/server/routes/transaction/blockchain/get-logs.ts index 30e416041..9ce20ef5f 100644 --- a/src/server/routes/transaction/blockchain/get-logs.ts +++ b/src/server/routes/transaction/blockchain/get-logs.ts @@ -167,9 +167,9 @@ export async function getTransactionLogs(fastify: FastifyInstance) { // see https://github.com/thirdweb-dev/solutions-customer-scripts/blob/main/amex/scripts/load-backfill-via-api.ts // Fallback to backfill table if enabled and not found if (!hash && env.ENABLE_TX_BACKFILL_FALLBACK) { - const backfillHash = await TransactionDB.getBackfillHash(queueId); - if (backfillHash && isHex(backfillHash)) { - hash = backfillHash as Hex; + const backfill = await TransactionDB.getBackfill(queueId); + if (backfill?.status === "mined" && backfill.transactionHash && isHex(backfill.transactionHash)) { + hash = backfill.transactionHash as Hex; } } } else if (transactionHash) { diff --git a/src/server/routes/transaction/status.ts b/src/server/routes/transaction/status.ts index d18fa0333..86c35692d 100644 --- a/src/server/routes/transaction/status.ts +++ b/src/server/routes/transaction/status.ts @@ -2,6 +2,7 @@ import { type Static, Type } from "@sinclair/typebox"; import type { FastifyInstance } from "fastify"; import { StatusCodes } from "http-status-codes"; import { TransactionDB } from "../../../shared/db/transactions/db"; +import { env } from "../../../shared/utils/env"; import { createCustomError } from "../../middleware/error"; import { standardResponseSchema } from "../../schemas/shared-api-schemas"; import { @@ -9,6 +10,77 @@ import { toTransactionSchema, } from "../../schemas/transaction"; +/** + * Creates a minimal transaction response from backfill data. + * Used when the transaction is not found in Redis but exists in the backfill table. + */ +const createBackfillResponse = ( + queueId: string, + backfill: { status: "mined" | "errored"; transactionHash?: string }, +): Static => { + const baseResponse: Static = { + queueId, + status: backfill.status, + chainId: null, + fromAddress: null, + toAddress: null, + data: null, + extension: null, + value: null, + nonce: null, + gasLimit: null, + gasPrice: null, + maxFeePerGas: null, + maxPriorityFeePerGas: null, + transactionType: null, + transactionHash: null, + queuedAt: null, + sentAt: null, + minedAt: null, + cancelledAt: null, + deployedContractAddress: null, + deployedContractType: null, + errorMessage: null, + sentAtBlockNumber: null, + blockNumber: null, + retryCount: 0, + retryGasValues: null, + retryMaxFeePerGas: null, + retryMaxPriorityFeePerGas: null, + signerAddress: null, + accountAddress: null, + accountSalt: null, + accountFactoryAddress: null, + target: null, + sender: null, + initCode: null, + callData: null, + callGasLimit: null, + verificationGasLimit: null, + preVerificationGas: null, + paymasterAndData: null, + userOpHash: null, + functionName: null, + functionArgs: null, + onChainTxStatus: null, + onchainStatus: null, + effectiveGasPrice: null, + cumulativeGasUsed: null, + batchOperations: null, + }; + + if (backfill.status === "mined" && backfill.transactionHash) { + return { + ...baseResponse, + transactionHash: backfill.transactionHash, + onchainStatus: "success", + onChainTxStatus: 1, + }; + } + + return baseResponse; +}; + // INPUT const requestSchema = Type.Object({ queueId: Type.String({ @@ -75,6 +147,16 @@ export async function getTransactionStatusRoute(fastify: FastifyInstance) { const transaction = await TransactionDB.get(queueId); if (!transaction) { + // Fallback to backfill table if enabled + if (env.ENABLE_TX_BACKFILL_FALLBACK) { + const backfill = await TransactionDB.getBackfill(queueId); + if (backfill) { + return reply.status(StatusCodes.OK).send({ + result: createBackfillResponse(queueId, backfill), + }); + } + } + throw createCustomError( "Transaction not found.", StatusCodes.BAD_REQUEST, @@ -122,6 +204,16 @@ export async function getTransactionStatusQueryParamRoute( const transaction = await TransactionDB.get(queueId); if (!transaction) { + // Fallback to backfill table if enabled + if (env.ENABLE_TX_BACKFILL_FALLBACK) { + const backfill = await TransactionDB.getBackfill(queueId); + if (backfill) { + return reply.status(StatusCodes.OK).send({ + result: createBackfillResponse(queueId, backfill), + }); + } + } + throw createCustomError( "Transaction not found.", StatusCodes.BAD_REQUEST, diff --git a/src/shared/db/transactions/db.ts b/src/shared/db/transactions/db.ts index 9eed2fc58..605ae2a97 100644 --- a/src/shared/db/transactions/db.ts +++ b/src/shared/db/transactions/db.ts @@ -2,6 +2,15 @@ import superjson from "superjson"; import { MAX_REDIS_BATCH_SIZE, redis } from "../../utils/redis/redis"; import type { AnyTransaction } from "../../utils/transaction/types"; +/** + * Backfill entry stored as JSON in Redis. + * Used for transaction status and logs fallback lookup. + */ +export interface BackfillEntry { + status: "mined" | "errored"; + transactionHash?: string; // Only present for mined transactions +} + /** * Schemas * @@ -211,10 +220,30 @@ export class TransactionDB { }; /** + * Gets backfill entry from backfill table. + * Returns parsed JSON or handles backwards compatibility for plain string tx hashes. + */ + static getBackfill = async (queueId: string): Promise => { + const val = await redis.get(this.backfillKey(queueId)); + if (!val) return null; + try { + return JSON.parse(val) as BackfillEntry; + } catch { + // Backwards compatibility: treat plain string as mined tx hash + return { status: "mined", transactionHash: val }; + } + }; + + /** + * @deprecated Use getBackfill instead * Gets transaction hash from backfill table. */ static getBackfillHash = async (queueId: string): Promise => { - return redis.get(this.backfillKey(queueId)); + const backfill = await this.getBackfill(queueId); + if (backfill?.status === "mined" && backfill.transactionHash) { + return backfill.transactionHash; + } + return null; }; /** @@ -225,9 +254,10 @@ export class TransactionDB { queueId: string, transactionHash: string, ): Promise => { + const entry: BackfillEntry = { status: "mined", transactionHash }; const result = await redis.setnx( this.backfillKey(queueId), - transactionHash, + JSON.stringify(entry), ); return result === 1; }; @@ -237,14 +267,15 @@ export class TransactionDB { * @returns { inserted: number, skipped: number } */ static bulkSetBackfill = async ( - entries: Array<{ queueId: string; transactionHash: string }>, + entries: Array<{ queueId: string; status: "mined" | "errored"; transactionHash?: string }>, ): Promise<{ inserted: number; skipped: number }> => { let inserted = 0; let skipped = 0; const pipeline = redis.pipeline(); - for (const { queueId, transactionHash } of entries) { - pipeline.setnx(this.backfillKey(queueId), transactionHash); + for (const { queueId, status, transactionHash } of entries) { + const entry: BackfillEntry = { status, transactionHash }; + pipeline.setnx(this.backfillKey(queueId), JSON.stringify(entry)); } const results = await pipeline.exec(); From cd678e071620ed790103e103301ad396dc5fafd7 Mon Sep 17 00:00:00 2001 From: Eiman Date: Mon, 2 Feb 2026 09:17:15 -0600 Subject: [PATCH 6/7] docs: add AMEX backfill comments to /transaction/status routes Co-Authored-By: Claude Opus 4.5 --- src/server/routes/transaction/status.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/server/routes/transaction/status.ts b/src/server/routes/transaction/status.ts index 86c35692d..91f286ceb 100644 --- a/src/server/routes/transaction/status.ts +++ b/src/server/routes/transaction/status.ts @@ -147,7 +147,11 @@ export async function getTransactionStatusRoute(fastify: FastifyInstance) { const transaction = await TransactionDB.get(queueId); if (!transaction) { - // Fallback to backfill table if enabled + // SPECIAL LOGIC FOR AMEX + // AMEX uses this endpoint to check transaction status for queue IDs they didn't receive webhooks for. + // The queue ID's were cleaned out of Redis so we backfilled tx data to this backfill table. + // See https://github.com/thirdweb-dev/solutions-customer-scripts/blob/main/amex/scripts/load-backfill-via-api.ts + // Fallback to backfill table if enabled and not found if (env.ENABLE_TX_BACKFILL_FALLBACK) { const backfill = await TransactionDB.getBackfill(queueId); if (backfill) { @@ -204,7 +208,11 @@ export async function getTransactionStatusQueryParamRoute( const transaction = await TransactionDB.get(queueId); if (!transaction) { - // Fallback to backfill table if enabled + // SPECIAL LOGIC FOR AMEX + // AMEX uses this endpoint to check transaction status for queue IDs they didn't receive webhooks for. + // The queue ID's were cleaned out of Redis so we backfilled tx data to this backfill table. + // See https://github.com/thirdweb-dev/solutions-customer-scripts/blob/main/amex/scripts/load-backfill-via-api.ts + // Fallback to backfill table if enabled and not found if (env.ENABLE_TX_BACKFILL_FALLBACK) { const backfill = await TransactionDB.getBackfill(queueId); if (backfill) { From a9646f8378bc25ad7434704ea22583f54a910f40 Mon Sep 17 00:00:00 2001 From: Eiman Date: Mon, 2 Feb 2026 09:26:05 -0600 Subject: [PATCH 7/7] fix: use discriminated union for backfill schema and remove duplicate comment - Use discriminated union so transactionHash is required for mined entries - Remove duplicated AMEX comment block Co-Authored-By: Claude Opus 4.5 --- src/server/routes/admin/backfill.ts | 35 ++++++++++++++--------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/server/routes/admin/backfill.ts b/src/server/routes/admin/backfill.ts index e8201c4b3..1a417e503 100644 --- a/src/server/routes/admin/backfill.ts +++ b/src/server/routes/admin/backfill.ts @@ -5,27 +5,26 @@ import { TransactionDB } from "../../../shared/db/transactions/db"; import { standardResponseSchema } from "../../schemas/shared-api-schemas"; // SPECIAL LOGIC FOR AMEX -// added two admin routes to backfill tx hashes to the backfill table -// loadBackfillRoute and clearBackfillRoute -// loadBackfillRoute is used to load tx hashes to the backfill table -// clearBackfillRoute is used to clear the backfill table -// these routes are used by the AMEX script to backfill tx hashes to the backfill table -// see https://github.com/thirdweb-dev/solutions-customer-scripts/blob/main/amex/scripts/load-backfill-via-api.ts -// loadBackfillRoute is used to load tx hashes to the backfill table -// clearBackfillRoute is used to clear the backfill table -// these routes are used by the AMEX script to backfill tx hashes to the backfill table -// see https://github.com/thirdweb-dev/solutions-customer-scripts/blob/main/amex/scripts/load-backfill-via-api.ts +// Two admin routes to backfill transaction data: +// - loadBackfillRoute: Load queueId to status/transactionHash mappings +// - clearBackfillRoute: Clear all backfill entries +// See https://github.com/thirdweb-dev/solutions-customer-scripts/blob/main/amex/scripts/load-backfill-via-api.ts + +const MinedEntrySchema = Type.Object({ + queueId: Type.String({ description: "Queue ID (UUID)" }), + status: Type.Literal("mined"), + transactionHash: Type.String({ description: "Transaction hash (0x...)" }), +}); + +const ErroredEntrySchema = Type.Object({ + queueId: Type.String({ description: "Queue ID (UUID)" }), + status: Type.Literal("errored"), +}); const loadRequestBodySchema = Type.Object({ entries: Type.Array( - Type.Object({ - queueId: Type.String({ description: "Queue ID (UUID)" }), - status: Type.Union([Type.Literal("mined"), Type.Literal("errored")], { - description: "Transaction status: 'mined' for successful transactions, 'errored' for failed ones", - }), - transactionHash: Type.Optional( - Type.String({ description: "Transaction hash (0x...). Required for mined transactions." }), - ), + Type.Union([MinedEntrySchema, ErroredEntrySchema], { + description: "Entry with status 'mined' requires transactionHash; status 'errored' does not", }), { description: "Array of queueId to status/transactionHash mappings",