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:
- Before on-chain call: Check if a previous attempt already submitted a tx with this idempotency key
- After on-chain call: Store the tx hash alongside the idempotency key
- On retry: If the key exists with a tx hash, the client already got the result — return it
- 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:
- Generate a UUID idempotency key before each claim/mint request
- Store it in localStorage alongside the request
- On retry, reuse the same key
- 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
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
POST /api/rewards/claimPOST /api/credentials/mintThe timeout scenario
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
rewardClaimedin the DB doesn't help.Current code paths
Reward claim (
src/modules/rewards/reward.service.ts:24-103):claim_rewardpanics on double-claim (line 210 of contract), but the error is not caught gracefullyCredential mint (
src/modules/credentials/credential.service.ts:46-99):Required Implementation
A. Database Schema for Idempotency
B. Idempotency Middleware
C. Updated Route Handlers
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:E. Cleanup Job
F. Client-Side Contract
The frontend must:
Files to create/modify
src/middleware/idempotency.tssrc/database/schema.ts— addidempotencyKeystablesrc/modules/rewards/reward.routes.ts— accept idempotency keysrc/modules/rewards/reward.service.ts— integrate idempotency checksrc/modules/credentials/credential.routes.ts— accept idempotency keysrc/modules/credentials/credential.service.ts— integrate idempotency checksrc/jobs/cleanup-idempotency.ts— scheduled cleanupTesting Requirements
References