|
| 1 | +# Idempotent Retry-Safe Transfer Orchestration (Issue #1043) |
| 2 | + |
| 3 | +A vault transfer moves money. `submitVaultOperation` builds a **fresh** |
| 4 | +transaction — fresh sequence number, fresh signature — and pushes it to the |
| 5 | +network. So retrying that call is not a repeat of the first attempt; it is a |
| 6 | +second transaction. |
| 7 | + |
| 8 | +That makes "just retry it" the wrong default. `src/transferOrchestrator.ts` is |
| 9 | +the service that decides, for every failure, whether a retry is safe. |
| 10 | + |
| 11 | +## What the previous implementation covered, and what it didn't |
| 12 | + |
| 13 | +The original service wrapped the RPC in `IdempotencyStore.execute`. That gives |
| 14 | +the happy path: same key twice → one transaction, second caller replays the |
| 15 | +stored response. Everything else was open: |
| 16 | + |
| 17 | +| Gap | Consequence | |
| 18 | +| --- | --- | |
| 19 | +| Client key used verbatim as the store key | Wallet A's key `"1"` occupied wallet B's slot — B replayed A's transaction hash or got a spurious 409 | |
| 20 | +| No request validation | A negative or `NaN` amount reached the signing path | |
| 21 | +| Every failure retried blind | A failure *after* the envelope hit the network was retried, which can transfer twice | |
| 22 | +| Terminal rejections not stored | A guaranteed-to-fail 422 re-ran the full build/simulate path on every retry | |
| 23 | +| No fail-fast, no timeout | A dead RPC absorbed the whole retry budget per request; a hung call held the key's in-flight slot for the life of the process | |
| 24 | +| No metrics, no alert | None of the above was visible | |
| 25 | + |
| 26 | +## The retry-safety contract |
| 27 | + |
| 28 | +> For a given **(wallet, idempotency key)** pair, at most one transaction is ever |
| 29 | +> submitted. Every later call either replays the stored outcome or fails loudly. |
| 30 | +> It never silently submits a second transaction. |
| 31 | +
|
| 32 | +## Failure classification |
| 33 | + |
| 34 | +Everything rests on one question: *did the signed envelope reach the network?* |
| 35 | + |
| 36 | +| Class | Meaning | Stored under the key? | Caller may retry? | |
| 37 | +| --- | --- | --- | --- | |
| 38 | +| `retryable` | Proven to have failed **before** submission. Nothing moved. | No — so the retry re-executes | Yes, same key | |
| 39 | +| `terminal` | The request itself is invalid; retrying cannot help. | Yes, as a `rejected` record | Retrying replays the same rejection | |
| 40 | +| `indeterminate` | The envelope may or may not have landed. | Yes, as an `in_doubt` record | **No** — blocked until reconciled | |
| 41 | + |
| 42 | +The mapping comes from reading `submitVaultOperation`'s own error codes: |
| 43 | + |
| 44 | +| Soroban code | Class | Why | |
| 45 | +| --- | --- | --- | |
| 46 | +| `INVALID_ADDRESS`, `INVALID_AMOUNT` | `terminal` | Argument validation, raised before anything is built | |
| 47 | +| `SIMULATION_ERROR` | `retryable` | Raised on the simulate path; no envelope was sent | |
| 48 | +| `RESTORE_REQUIRED` | `retryable` | Simulation asked for a ledger restore; nothing was sent | |
| 49 | +| `RPC_ERROR` | `retryable` | RPC returned `status === 'ERROR'` — an explicit rejection, so no transaction exists | |
| 50 | +| `SOROBAN_CIRCUIT_OPEN` | `retryable` | Fail-fast; the RPC was never called | |
| 51 | +| `SUBMISSION_FAILED` | `indeterminate` | Submit returned an unexpected status — the envelope was already handed over | |
| 52 | +| `INTERNAL_ERROR` | `indeterminate` | Catch-all wrapper; may be a socket error mid-submit | |
| 53 | +| `TRANSFER_TIMEOUT` | `indeterminate` | The call never settled; silence is not proof | |
| 54 | + |
| 55 | +**Unknown failures default to `indeterminate`.** This is deliberately the |
| 56 | +opposite of `classifyWithdrawalFailure`, which defaults to `retryable`. That |
| 57 | +coordinator retries *idempotent* steps; a retry here mints a new transaction. |
| 58 | +When we cannot prove nothing moved, we refuse to move again. |
| 59 | + |
| 60 | +## Wallet-scoped keys |
| 61 | + |
| 62 | +The client's key never reaches the store directly: |
| 63 | + |
| 64 | +``` |
| 65 | +transfer:<sha256(normalizedWallet)[0..16]>:<clientKey> |
| 66 | +``` |
| 67 | + |
| 68 | +Two wallets can therefore both use `"checkout-1"` without colliding. The wallet |
| 69 | +is hashed rather than embedded so the key stays bounded and no address leaks into |
| 70 | +a Redis `KEYS`/`SCAN` listing. Normalisation is case-insensitive, so |
| 71 | +`gabc…`/`GABC…` are one wallet. |
| 72 | + |
| 73 | +Keys must be 8–255 characters (`TRANSFER_ORCHESTRATION_MAX_KEY_LENGTH`) from |
| 74 | +`[A-Za-z0-9._:~-]`. The charset rules out whitespace and newlines, which are |
| 75 | +unsafe in a Redis key segment; the minimum length rules out trivially guessable |
| 76 | +keys that invite cross-request collisions within one wallet. |
| 77 | + |
| 78 | +## Request canonicalisation |
| 79 | + |
| 80 | +Before fingerprinting, the request is canonicalised: wallet upper-cased, asset |
| 81 | +upper-cased, amount trimmed. Two spellings of the same transfer therefore share |
| 82 | +one fingerprint and replay correctly instead of raising a false conflict. |
| 83 | + |
| 84 | +Amounts must match `^\d+(\.\d+)?$` and be `> 0`. Strings are used so JSON cannot |
| 85 | +round the value, and the pattern rejects `NaN`, `Infinity`, `1e3` and signed |
| 86 | +values — all of which `Number()` would otherwise coerce further down the stack. |
| 87 | + |
| 88 | +## The in-doubt window |
| 89 | + |
| 90 | +When a failure is `indeterminate`, the orchestrator: |
| 91 | + |
| 92 | +1. Stores an `in_doubt` record under the key. **This is the mechanism that stops |
| 93 | + a retry** — the next call hits the stored record instead of the RPC. |
| 94 | +2. Registers the transfer in the in-doubt registry with everything an operator |
| 95 | + needs to reconcile it: wallet, operation, amount, asset, failing code, the |
| 96 | + original idempotency key, and the trace ID. |
| 97 | +3. Logs at `error` with `alert: "transfer-in-doubt"` and raises the in-doubt |
| 98 | + gauge. |
| 99 | +4. Throws `TransferInDoubtError` (409) — to the caller that opened the window and |
| 100 | + to every caller after it. |
| 101 | + |
| 102 | +### Reconciling |
| 103 | + |
| 104 | +An operator checks the chain, then calls: |
| 105 | + |
| 106 | +```ts |
| 107 | +// The transfer did land — replays of the original key now return this hash. |
| 108 | +await resolveInDoubtTransfer(storeKey, { transactionHash: 'abc…' }); |
| 109 | + |
| 110 | +// Nothing landed — release the key so the client may retry from scratch. |
| 111 | +await resolveInDoubtTransfer(storeKey, { discard: true }); |
| 112 | +``` |
| 113 | + |
| 114 | +`listInDoubtTransfers()` and `getInDoubtTransfer(storeKey)` expose the queue. |
| 115 | +Neither leaks the internal fingerprint. |
| 116 | + |
| 117 | +## Errors |
| 118 | + |
| 119 | +Every failure is a `TransferOrchestrationError` subclass carrying `code`, |
| 120 | +`statusCode` and `classification`, so an HTTP layer can map them uniformly. |
| 121 | + |
| 122 | +| Error | Status | When | |
| 123 | +| --- | --- | --- | |
| 124 | +| `TransferValidationError` | 400 / 422 | Malformed key or request; never reaches the network | |
| 125 | +| `TransferConflictError` | 409 | Key reused with a different body | |
| 126 | +| `TransferInDoubtError` | 409 | Outcome unknown; needs reconciliation | |
| 127 | +| `TransferUnavailableError` | 503 | Dependency down, nothing submitted (`retryAfterMs` set when the circuit is open) | |
| 128 | +| `TransferOrchestrationError` | 422 (stored) | A replayed terminal rejection | |
| 129 | + |
| 130 | +## Metrics |
| 131 | + |
| 132 | +| Metric | Type | Labels | |
| 133 | +| --- | --- | --- | |
| 134 | +| `transfer_orchestration_total` | counter | `operation`, `outcome` | |
| 135 | +| `transfer_orchestration_replay_total` | counter | `operation`, `replay_of` | |
| 136 | +| `transfer_orchestration_failure_total` | counter | `operation`, `classification`, `code` | |
| 137 | +| `transfer_orchestration_duration_ms` | histogram | `operation`, `outcome` | |
| 138 | +| `transfer_orchestration_in_doubt` | gauge | — | |
| 139 | + |
| 140 | +`transfer_orchestration_in_doubt > 0` is the page-worthy signal: money may have |
| 141 | +moved without a ledger record. Pair it with the `transfer-in-doubt` log alert. |
| 142 | + |
| 143 | +## Configuration |
| 144 | + |
| 145 | +| Variable | Default | Purpose | |
| 146 | +| --- | --- | --- | |
| 147 | +| `TRANSFER_ORCHESTRATION_TIMEOUT_MS` | `45000` | Caps a single submission so a hung RPC cannot pin the key's in-flight slot. A timeout is classified `indeterminate`. | |
| 148 | +| `TRANSFER_ORCHESTRATION_MAX_KEY_LENGTH` | `255` | Upper bound on a client-supplied key. | |
| 149 | + |
| 150 | +Both are read per call, so an operator can change them without a restart-time |
| 151 | +re-import. The circuit breaker and idempotency TTL are configured by their own |
| 152 | +modules (`CIRCUIT_BREAKER_*`, `IDEMPOTENCY_KEY_TTL_MS`). |
| 153 | + |
| 154 | +## Durability note |
| 155 | + |
| 156 | +The in-doubt registry is in-process, the same trade-off the withdrawal saga |
| 157 | +journal and the dead-letter queue make. The `in_doubt` record that actually |
| 158 | +enforces retry-safety lives in the idempotency store, which **is** shared across |
| 159 | +replicas via Redis — so a pod recycle cannot turn a parked transfer back into a |
| 160 | +resubmittable one. What a recycle loses is the operator *queue* view, not the |
| 161 | +guarantee. Mirror the `transfer-in-doubt` alert to durable storage if you need |
| 162 | +the queue to survive a restart. |
| 163 | + |
| 164 | +## Usage |
| 165 | + |
| 166 | +```ts |
| 167 | +import { orchestrateTransfer } from './transferOrchestrator'; |
| 168 | + |
| 169 | +const { transactionHash, replayed } = await orchestrateTransfer( |
| 170 | + { operationType: 'deposit', walletAddress, amount: '1000', asset: 'USDC' }, |
| 171 | + request.header('Idempotency-Key')!, |
| 172 | +); |
| 173 | +``` |
| 174 | + |
| 175 | +Call sites adopt the service by calling `orchestrateTransfer` in place of |
| 176 | +`submitVaultOperation` and mapping the typed errors above onto responses. |
| 177 | + |
| 178 | +## Tests |
| 179 | + |
| 180 | +`src/__tests__/transferOrchestrator.test.ts` covers submission and replay, |
| 181 | +concurrent coalescing, canonicalisation, wallet-scoped key isolation, conflict |
| 182 | +detection, key/amount/wallet validation, each classification branch, fail-fast on |
| 183 | +an open circuit, stored terminal rejections, the timeout path, and both in-doubt |
| 184 | +resolutions — including the central assertion that a retry after an |
| 185 | +indeterminate failure does **not** call the RPC a second time. |
0 commit comments