Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/TRANSFER_LIFECYCLE_CONCURRENCY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Transfer lifecycle concurrency

Transfer creation already uses actor-scoped idempotency. Terminal lifecycle
mutations now add optimistic resource versions.

## Client contract

Every single-transfer response carries an ETag containing the integer transfer
version, for example:

ETag: "1"

To claim or cancel a transfer, clients send both:

If-Match: "1"
Idempotency-Key: <stable operation key>

The server reserves the actor/key pair before provider work. A repeated request
with the same key replays the first terminal result. A request based on an old
version returns HTTP 409 with expected version, actual version, and current
status. A missing If-Match returns HTTP 428. Invalid state transitions also
return 409.

Creation starts at version 1. Every lifecycle, archive, or unarchive mutation
increments the version.

## Commit order

Terminal mutations follow this order:

1. reserve the actor-scoped operation key;
2. compare expected version and allowed transition;
3. prepare the provider-side artifact with a stable provider operation key;
4. re-check observed status and version;
5. commit status, version, provider result, and replay receipt;
6. append the audit event.

A provider failure occurs before the local terminal commit, so the transfer
remains pending and the reservation is released for a safe retry. The mock
Stellar adapter models provider-side idempotency by remembering operation
receipts independently from application transfer state.

## Storage boundary

The current demo store is process-local. Its mutation is synchronous, so the
version check plus mutation is a compare-and-set within this process. When the
store moves to a database, preserve the contract atomically with an update
constrained by transfer id and version, and move lifecycle idempotency records
to the same durable/shared boundary so multiple workers share reservation and
replay state.

The regression source in test/transferLifecycleConcurrency.test.js covers the
state-machine race, duplicate provider callback, service-worker reload,
provider rollback/retry, and one-terminal-outcome behavior.
63 changes: 55 additions & 8 deletions src/controllers/transferController.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,41 @@ function requireIdempotencyKey(req) {
return key;
}

/**
* Parse the strong ETag version used as the optimistic precondition for
* terminal transfer mutations.
* @param {import('express').Request} req
* @returns {number}
*/
function requireTransferVersion(req) {
const raw = req.get('If-Match');
if (typeof raw !== 'string' || raw.trim() === '') {
throw new ApiError(
428,
'If-Match header is required for transfer lifecycle mutations'
);
}

const match = /^"([1-9][0-9]*)"$/.exec(raw.trim());
if (!match) {
throw ApiError.badRequest(
'If-Match must contain the quoted transfer version, for example "1"'
);
}
return Number(match[1]);
}

/**
* Return a transfer with its current version as a strong ETag.
* @param {import('express').Response} res
* @param {object} transfer
* @param {number} [status]
*/
function sendTransfer(res, transfer, status = 200) {
res.set('ETag', `"${transfer.version}"`);
res.status(status).json(transfer);
}

/**
* Transfer controllers.
*/
Expand Down Expand Up @@ -70,7 +105,7 @@ function createTransfer(req, res) {
// did. Replaying the stored result means replaying all of it; downgrading the
// status would make a successful retry look different from the response it is
// standing in for.
res.status(201).json(transfer);
sendTransfer(res, transfer, 201);
}

/**
Expand Down Expand Up @@ -127,25 +162,37 @@ function getStats(req, res) {
*/
function getTransfer(req, res) {
const transfer = transferService.getTransferOrThrow(req.params.id);
res.json(transfer);
sendTransfer(res, transfer);
}

/**
* POST /api/transfers/:id/claim
* Mark a transfer as claimed by the recipient.
*/
function claimTransfer(req, res) {
const transfer = transferService.claimTransfer(req.params.id, req.id);
res.json(transfer);
const expectedVersion = requireTransferVersion(req);
const key = requireIdempotencyKey(req);
const transfer = transferService.claimTransfer(req.params.id, req.id, {
actor: req.token,
key,
expectedVersion,
});
sendTransfer(res, transfer);
}

/**
* POST /api/transfers/:id/cancel
* Cancel a pending transfer.
*/
function cancelTransfer(req, res) {
const transfer = transferService.cancelTransfer(req.params.id, req.id);
res.json(transfer);
const expectedVersion = requireTransferVersion(req);
const key = requireIdempotencyKey(req);
const transfer = transferService.cancelTransfer(req.params.id, req.id, {
actor: req.token,
key,
expectedVersion,
});
sendTransfer(res, transfer);
}

/**
Expand All @@ -154,7 +201,7 @@ function cancelTransfer(req, res) {
*/
function archiveTransfer(req, res) {
const transfer = transferService.archiveTransfer(req.params.id);
res.json(transfer);
sendTransfer(res, transfer);
}

/**
Expand All @@ -163,7 +210,7 @@ function archiveTransfer(req, res) {
*/
function unarchiveTransfer(req, res) {
const transfer = transferService.unarchiveTransfer(req.params.id);
res.json(transfer);
sendTransfer(res, transfer);
}

module.exports = {
Expand Down
30 changes: 26 additions & 4 deletions src/services/stellarService.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ const config = require('../config');
const { prefixedId } = require('../utils/ids');
const logger = require('../utils/logger');

// Mock provider receipts live outside application transfer state. A real
// payment provider offers the same property through idempotency keys: retrying
// an ambiguous request returns the first settlement artifact.
const paymentReceipts = new Map();
const claimableBalanceReceipts = new Map();

/**
* Mock Stellar integration.
* Real RemitFlow would submit path payments to the Stellar network.
Expand All @@ -18,21 +24,37 @@ const logger = require('../utils/logger');
* @param {string} params.currency
* @returns {{ txHash: string, network: string, ledger: number }}
*/
function submitPayment({ amount, currency }) {
function submitPayment({ amount, currency, idempotencyKey }) {
if (idempotencyKey && paymentReceipts.has(idempotencyKey)) {
return paymentReceipts.get(idempotencyKey);
}

logger.debug(`Submitting mock Stellar payment of ${amount} ${currency}`);
return {
const result = {
txHash: prefixedId('stellar').replace('stellar_', ''),
network: config.stellar.network,
ledger: Math.floor(Date.now() / 1000),
};
if (idempotencyKey) {
paymentReceipts.set(idempotencyKey, result);
}
return result;
}

/**
* Generate a mock claimable-balance id used when a recipient claims funds.
* @returns {string}
*/
function createClaimableBalanceId() {
return prefixedId('cb');
function createClaimableBalanceId(operationId) {
if (operationId && claimableBalanceReceipts.has(operationId)) {
return claimableBalanceReceipts.get(operationId);
}

const id = prefixedId('cb');
if (operationId) {
claimableBalanceReceipts.set(operationId, id);
}
return id;
}

module.exports = {
Expand Down
Loading