Skip to content

fix(#1376): restore exported buildIdempotencyFingerprint helper - #1492

Merged
Junirezz merged 2 commits into
Junirezz:mainfrom
solaawojobi00-bit:fix/issue-1376-idempotency-fingerprint
Oct 3, 2026
Merged

Junirezz merged 2 commits into
Junirezz:mainfrom
solaawojobi00-bit:fix/issue-1376-idempotency-fingerprint

Conversation

@solaawojobi00-bit

Copy link
Copy Markdown
Contributor

fix(#1376): restore exported buildIdempotencyFingerprint helper

Problem

backend/src/transferOrchestrator.ts fingerprints every canonicalised transfer with buildIdempotencyFingerprint from ./idempotency, then hands that fingerprint to the idempotency store. Commit 5db5f6e (the tenant-boundaries refactor) replaced backend/src/idempotency.ts wholesale and dropped the helper along with its getIdempotencyHashThreshold and stableStringify companions. Nothing else was removed from the import list's point of view, so the missing export is easy to miss:

  • ts-jest runs with diagnostics: false, so the unresolved import doesn't fail test compilation.
  • At runtime a CommonJS named import of a missing export is undefined, so buildIdempotencyFingerprint(canonical) throws on the first transfer.
  • Even when a fingerprint is produced, it must be identical for a retry. If it isn't (for example, a body sent with a different key order), the store treats the retry as a new request and a second on-chain submission can go out.
Scenario Before After
import { buildIdempotencyFingerprint } from './idempotency' undefined (not exported) exported function
Retry with the same body, keys reordered (including nested objects) no fingerprint to compare identical fingerprint, so the retry replays
Retry with a cloned or JSON-round-tripped body no fingerprint to compare identical fingerprint
Body differs in a value (e.g. amount) no fingerprint to compare different fingerprint, so a conflict is raised
Date vs. the equivalent ISO string no fingerprint to compare identical fingerprint
Body larger than IDEMPOTENCY_HASH_THRESHOLD_BYTES no fingerprint to compare deterministic hashv1:<sha256> digest

Solution

Restore the fingerprint helper exactly as it was before the refactor: same function names and signatures, same behaviour, so fingerprints match those produced before the refactor. The helper builds a canonical string by sorting object keys at every level while keeping array order. It serialises Date values as ISO-8601, and hashes the result with SHA-256 (prefix hashv1:) when it's larger than the configurable byte threshold (default 4096).

Changes

backend/src/idempotency.ts

export function getIdempotencyHashThreshold(): number {
  return parseInt(process.env.IDEMPOTENCY_HASH_THRESHOLD_BYTES || '4096', 10);
}

export function buildIdempotencyFingerprint(payload: unknown): string {
  const stable = stableStringify(payload);
  const byteLength = Buffer.byteLength(stable, 'utf-8');
  if (byteLength > getIdempotencyHashThreshold()) {
    return `hashv1:${crypto.createHash('sha256').update(stable).digest('hex')}`;
  }
  return stable;
}

function stableStringify(value: unknown): string { /* sorted-key canonical JSON */ }

Rationale: this matches the pre-refactor implementation (git show 5db5f6e0^:backend/src/idempotency.ts) line for line, so there's no change in fingerprint format or behaviour. crypto was already imported. The code is appended at the end of the file and doesn't touch the refactor's Prisma-backed record API.

backend/src/__tests__/idempotencyFingerprint.test.ts (new)

A focused suite that imports only the helper, so it doesn't depend on any other part of idempotency.ts. It checks that the helper is exported, that fingerprints are stable, and that the threshold behaves as expected.

Regression Tests

Acceptance criterion (from issue) Test
Fingerprint helper must be exported is exported from idempotency.ts for the transfer orchestrator
Stable: a retry must fingerprint identically produces the same fingerprint for a retry with reordered keys
Stable: deterministic across calls and object identity is deterministic across repeated calls and cloned payloads
No false replays: different requests must not collide distinguishes payloads that differ in value, treats array order as significant
Stable canonical form for non-plain values serialises Dates as ISO strings and handles primitives and null
Stable for large payloads (hashed path) hashes payloads above the threshold into a stable, key-order-independent digest
Threshold contract defaults the hash threshold to 4096 bytes, keeps payloads at or below the threshold as their stable string form

Testing

$ npx jest --runInBand --coverage=false src/__tests__/idempotencyFingerprint.test.ts
PASS src/__tests__/idempotencyFingerprint.test.ts (40.437 s)
  Issue #1376: buildIdempotencyFingerprint
    √ is exported from idempotency.ts for the transfer orchestrator (10 ms)
    √ produces the same fingerprint for a retry with reordered keys (2 ms)
    √ is deterministic across repeated calls and cloned payloads (4 ms)
    √ distinguishes payloads that differ in value (1 ms)
    √ treats array order as significant (1 ms)
    √ serialises Dates as ISO strings and handles primitives and null (16 ms)
    √ defaults the hash threshold to 4096 bytes (1 ms)
    √ hashes payloads above the threshold into a stable, key-order-independent digest (2 ms)
    √ keeps payloads at or below the threshold as their stable string form (1 ms)

Test Suites: 1 passed, 1 total
Tests:       9 passed, 9 total

The same suite with the idempotency.ts change reverted:

Tests:       9 failed, 9 total

npx tsc --noEmit reports no errors in idempotency.ts or the new test file.

Notes for Reviewers

  • Scope: this PR restores only the fingerprint helper named in the issue. transferOrchestrator.ts also imports idempotencyStore, IdempotencyConflictError and IdempotentOperationResult, which the same refactor removed. Restoring those is separate work (the idempotency response store, related to idempotency retention sweep lacks a dry-run safe path for production rehearsals #1375). End-to-end transfer retry safety needs both changes.
  • Merge overlap: the idempotency retention sweep lacks a dry-run safe path for production rehearsals #1375 work adds a byte-identical copy of this helper. Whichever PR merges second will get a small conflict in idempotency.ts; resolve it by keeping a single copy.
  • Risk: low. The change adds one helper and has no schema, API or contract impact. Existing fingerprints stay the same because the format (hashv1: prefix, 4096-byte default) is unchanged.
  • Rollback: a clean git revert works; the change leaves no persisted state behind.
  • CI: several workflows were already failing on main at 711c433 before this change (Backend Governance, Test Coverage, CodeQL, Flaky Test Detector, dependency audits).

Closes #1376

The tenant-boundaries refactor rewrote idempotency.ts and dropped the
fingerprint helper that transferOrchestrator.ts imports, leaving retries
without a stable fingerprint. Restore buildIdempotencyFingerprint,
getIdempotencyHashThreshold and the stable stringifier, and add
regression tests for export and fingerprint stability.
@drips-wave

drips-wave Bot commented Sep 29, 2026

Copy link
Copy Markdown

@solaawojobi00-bit Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@solaawojobi00-bit

Copy link
Copy Markdown
Contributor Author

CI status: failures come from main, not from this PR

The red checks on this PR are the same ones failing on main at 711c433. Every one fails during setup or install, before any code touched here runs. This PR changes only backend/src/idempotency.ts (+30, appending the helper) and the new backend/src/__tests__/idempotencyFingerprint.test.ts.

Failing jobs Root cause (from job logs)
Backend build, Backend lint + test, backend-governance, Backend Test Coverage backend/prisma/schema.prisma:677: model WalletTenantAssociation has no closing } before model IdempotencyKey (Prisma P1012). Prisma generate fails, so every suite errors with @prisma/client did not initialize yet.
Verify CODEOWNERS, PR Template & Contribution Rules ERR_PNPM_LOCKFILE_CONFIG_MISMATCH: the overrides in package.json don't match the lockfile.
CodeQL (TypeScript), Frontend Test Coverage npm ci fails with EUSAGE: the frontend package-lock.json is out of sync (@tanstack/query-sync-storage-persister, @tanstack/react-query-persist-client, framer-motion missing).
CodeQL (Rust), Cargo Security Audit, NPM/PNPM audits, Dependency Vulnerability Scan, GitHub Dependency Review Existing toolchain and dependency-audit failures across the repo.

#1491 fails the same set of 13 jobs. These failures are deterministic, not flakes, so re-running won't clear them.

Verified locally for this change:

PASS src/__tests__/idempotencyFingerprint.test.ts
Tests:       9 passed, 9 total

With the idempotency.ts change reverted, the same suite gives 9 failed, 9 total.

…potency-fingerprint

# Conflicts:
#	backend/src/idempotency.ts
@Junirezz
Junirezz merged commit 04bb2b7 into Junirezz:main Oct 3, 2026
14 of 27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transfer orchestrator loses idempotency fingerprint during retry causing duplicate submissions

2 participants