Skip to content

[Expert] Implement idempotency key system for all blockchain transaction endpoints #7

@DeFiVC

Description

@DeFiVC

Description

Blockchain transactions are irreversible. Once a Stellar transaction is submitted and confirmed, it cannot be undone. The current API has no idempotency mechanism for any endpoint that submits on-chain transactions. If a client's HTTP request times out after the on-chain transaction succeeds, they have no safe way to retry — they might double-mint tokens or NFTs.

Problem Analysis

Affected endpoints

Endpoint On-chain action Risk of duplicate
POST /api/rewards/claim Mint LEARN tokens Double token mint
POST /api/credentials/mint Mint credential NFT Duplicate NFT

The timeout scenario

Client                    API                     Stellar
  |                        |                         |
  |-- POST /rewards/claim->|                         |
  |                        |-- invokeContract ------>|
  |                        |                         |-- Confirmed!
  |                        |<-- tx hash -------------|
  |                        |                         |
  |   [HTTP timeout 30s]   |-- UPDATE db ----------->|
  |                        |                         |
  |<-- 504 Gateway Timeout |                         |
  |                        |                         |
  |-- POST /rewards/claim->|  (retry)                |
  |                        |-- claim_reward -------->|
  |                        |   PANIC: already claimed|

The client gets a 504, retries, and the on-chain call panics because the reward was already claimed. But the DB might not have been updated yet (race condition), so even checking rewardClaimed in the DB doesn't help.

Current code paths

Reward claim (src/modules/rewards/reward.service.ts:24-103):

  • No idempotency key in request body
  • No idempotency table in database
  • On-chain claim_reward panics on double-claim (line 210 of contract), but the error is not caught gracefully

Credential mint (src/modules/credentials/credential.service.ts:46-99):

  • Same pattern — no idempotency key, no stored result

Required Implementation

A. Database Schema for Idempotency

CREATE TABLE idempotency_keys (
  key             VARCHAR(64) PRIMARY KEY,
  user_id         UUID NOT NULL REFERENCES users(id),
  endpoint        VARCHAR(255) NOT NULL,
  request_hash    VARCHAR(64) NOT NULL,    -- SHA-256 of request body
  response_status INTEGER,
  response_body   JSONB,
  tx_hash         VARCHAR(64),             -- Stellar tx hash if on-chain
  created_at      TIMESTAMPTZ DEFAULT NOW(),
  expires_at      TIMESTAMPTZ NOT NULL     -- Auto-cleanup after 24h
);

CREATE INDEX idx_idempotency_expires ON idempotency_keys(expires_at);

B. Idempotency Middleware

// New file: src/middleware/idempotency.ts
import crypto from "node:crypto";
import { db } from "../config/database.js";
import { idempotencyKeys } from "../database/schema.js";
import { eq, and, lt } from "drizzle-orm";

export async function checkIdempotency(
  key: string,
  userId: string,
  endpoint: string,
  requestBody: unknown
): Promise<{ cached: boolean; response?: any }> {
  const requestHash = crypto
    .createHash("sha256")
    .update(JSON.stringify(requestBody))
    .digest("hex");

  // Check for existing key
  const existing = await db.query.idempotencyKeys.findFirst({
    where: eq(idempotencyKeys.key, key),
  });

  if (existing) {
    // Same request hash → return cached response
    if (existing.requestHash === requestHash && existing.responseBody) {
      return { cached: true, response: existing.responseBody };
    }
    // Different request hash → conflict
    throw new ConflictError("Idempotency key reused with different request body");
  }

  // Store new key (will be populated after processing)
  await db.insert(idempotencyKeys).values({
    key,
    userId,
    endpoint,
    requestHash,
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
  });

  return { cached: false };
}

export async function storeIdempotentResponse(
  key: string,
  status: number,
  body: any,
  txHash?: string
): Promise<void> {
  await db
    .update(idempotencyKeys)
    .set({
      responseStatus: status,
      responseBody: body,
      txHash: txHash ?? null,
    })
    .where(eq(idempotencyKeys.key, key));
}

C. Updated Route Handlers

// In reward.routes.ts
app.post("/claim", {
  preHandler: [authGuard],
  schema: {
    body: z.object({
      submissionId: z.string().uuid(),
      idempotencyKey: z.string().min(16).max(64),  // Client-generated
    }),
  },
}, async (request, reply) => {
  const { submissionId, idempotencyKey } = request.body as any;
  const userId = (request as AuthenticatedRequest).authUser!.id;

  // Check idempotency
  const { cached, response } = await checkIdempotency(
    idempotencyKey, userId, "/rewards/claim", request.body
  );

  if (cached) {
    return reply.status(response.status).send(response.body);
  }

  // Process claim
  try {
    const result = await rewardService.claimReward(userId, submissionId);

    await storeIdempotentResponse(idempotencyKey, 200, {
      success: true,
      data: result,
    }, result.txHash);

    return reply.status(200).send({ success: true, data: result });
  } catch (err) {
    await storeIdempotentResponse(idempotencyKey, err.statusCode ?? 500, {
      success: false,
      error: err.message,
    });
    throw err;
  }
});

D. Stellar Transaction Hash as Natural Idempotency Key

The Stellar blockchain itself provides idempotency for claim_reward — the contract panics on double-claim. But this is a brittle safety net. The idempotency key system should:

  1. Before on-chain call: Check if a previous attempt already submitted a tx with this idempotency key
  2. After on-chain call: Store the tx hash alongside the idempotency key
  3. On retry: If the key exists with a tx hash, the client already got the result — return it
  4. On retry with no tx hash: The previous attempt timed out before completing — check the chain for the tx

E. Cleanup Job

// Run daily to purge expired idempotency keys
async function cleanupIdempotencyKeys() {
  await db
    .delete(idempotencyKeys)
    .where(lt(idempotencyKeys.expiresAt, new Date()));
}

F. Client-Side Contract

The frontend must:

  1. Generate a UUID idempotency key before each claim/mint request
  2. Store it in localStorage alongside the request
  3. On retry, reuse the same key
  4. Handle 409 (key reused with different body) by aborting

Files to create/modify

  • New: src/middleware/idempotency.ts
  • New: src/database/schema.ts — add idempotencyKeys table
  • New: SQL migration for the table
  • Modify: src/modules/rewards/reward.routes.ts — accept idempotency key
  • Modify: src/modules/rewards/reward.service.ts — integrate idempotency check
  • Modify: src/modules/credentials/credential.routes.ts — accept idempotency key
  • Modify: src/modules/credentials/credential.service.ts — integrate idempotency check
  • New: src/jobs/cleanup-idempotency.ts — scheduled cleanup

Testing Requirements

  • Submit same idempotency key twice with same body → second returns cached response
  • Submit same idempotency key twice with different body → 409 Conflict
  • Submit claim, mock timeout after on-chain succeeds, retry with same key → returns original result
  • Concurrent requests with same idempotency key → only one processes
  • Verify expired keys are cleaned up

References

Metadata

Metadata

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignadvancedAdvanced difficultyenhancementNew feature or requesttypescriptTypeScript language

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions