Skip to content

Feature/add idempotency key support to refund invoice - #314

Merged
levoski1 merged 2 commits into
WHEELBACK:mainfrom
i-amdaveee:feature/add-idempotency-key-support-to-refund-invoice
Jul 30, 2026
Merged

Feature/add idempotency key support to refund invoice#314
levoski1 merged 2 commits into
WHEELBACK:mainfrom
i-amdaveee:feature/add-idempotency-key-support-to-refund-invoice

Conversation

@i-amdaveee

Copy link
Copy Markdown
Contributor

Summary

Closes #200

Adds Idempotency-Key header support to refund_invoice, and applies the same
pattern to pay_invoice and cancel_invoice to prevent double-processing on retries.

⚠️ Security/correctness-sensitive change — this directly prevents double-refunds
when a client retries after a timeout or network failure.

Problem

backend/src/routes/refund.rs had no idempotency handling. A client retrying a
timed-out refund request could cause the same invoice to be refunded twice.

What changed

New file: backend/src/idempotency.rs

  • In-memory store backed by DashMap with configurable TTL (default 24 h)
  • Keys are namespaced as "{endpoint}:{invoice_id}:{idempotency_key}" to prevent
    cross-endpoint collisions
  • Lazy TTL eviction on lookup

backend/src/main.rs

  • Introduced AppState struct holding Arc<SorobanClient> and Arc<IdempotencyStore>
  • All route handlers now receive AppState via State<AppState>

backend/src/routes/refund.rs (primary change)

  • Accepts Idempotency-Key header
  • On first request: processes normally, caches (status, body) keyed by the header value
  • On retry with same key: returns cached response immediately — Soroban is never called again

backend/src/routes/pay.rs and cancel.rs

  • Same idempotency pattern applied for consistency

How it works — request/response examples

First request:

POST /invoices/42/refund Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890 Content-Type: application/json

{ "payer": "GPAYER...", "signed_xdr": "AAAA==" }

→ 200 OK { "status": "refund_requested", "transaction_hash": "abc123..." }

Retry with same key (e.g. after timeout):
POST /invoices/42/refund Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890 Content-Type: application/json

{ "payer": "GPAYER...", "signed_xdr": "AAAA==" }

→ 200 OK ← returned from cache, Soroban NOT called { "status": "refund_requested", "transaction_hash": "abc123..." }

Request without header (backward compatible):
POST /invoices/42/refund Content-Type: application/json

{ "payer": "GPAYER...", "signed_xdr": "AAAA==" }

→ processed normally, no caching

cargo test output

test routes::cancel::tests::test_cancel_invoice_missing_body_returns_4xx ... ok test routes::cancel::tests::test_cancel_invoice_unreachable_rpc_returns_error ... ok test routes::cancel::tests::test_same_idempotency_key_returns_cached_response ... ok test routes::health::tests::returns_200_when_all_dependencies_are_healthy ... ok test routes::health::tests::returns_503_when_any_dependency_is_degraded ... ok test routes::pay::tests::test_pay_invoice_missing_body_returns_4xx ... ok test routes::pay::tests::test_pay_invoice_unreachable_rpc_returns_5xx_or_404 ... ok test routes::pay::tests::test_same_idempotency_key_returns_cached_response ... ok test routes::refund::tests::test_different_idempotency_key_is_independent ... ok test routes::refund::tests::test_refund_invoice_missing_body_returns_4xx ... ok test routes::refund::tests::test_refund_invoice_unreachable_rpc_returns_error ... ok test routes::refund::tests::test_same_idempotency_key_returns_cached_response ... ok

test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Notes

  • The idempotency store is in-memory. It resets on server restart. For a
    persistent/distributed deployment, swap IdempotencyStore for a Redis-backed
    implementation — the interface is the same.
  • The Idempotency-Key header is optional. Requests without it are processed
    normally (backward compatible).

@drips-wave

drips-wave Bot commented Jul 29, 2026

Copy link
Copy Markdown

@i-amdaveee 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

@levoski1
levoski1 merged commit 1fac9df into WHEELBACK:main Jul 30, 2026
1 of 7 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.

Add idempotency key support to refund_invoice

2 participants