Skip to content

feat: Handle partial sweep failure on contract-authorized / Horizon-failed path (#169)#268

Merged
phertyameen merged 3 commits into
bridgelet-org:mainfrom
dzekojohn4:feat/issue-169-partial-sweep-handling
Jul 1, 2026
Merged

feat: Handle partial sweep failure on contract-authorized / Horizon-failed path (#169)#268
phertyameen merged 3 commits into
bridgelet-org:mainfrom
dzekojohn4:feat/issue-169-partial-sweep-handling

Conversation

@dzekojohn4

@dzekojohn4 dzekojohn4 commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Changes

When the SweepController authorises a sweep but the subsequent Horizon
payment fails, the contract is in Swept state and no funds have
moved. The previous behaviour logged a CRITICAL error and required
manual recovery. Capture the partial outcome on the account instead so
a retry of the same claim token only re-submits the Horizon payment
(skipping execute_sweep, which would revert) and finishes the sweep.

  • Add AccountStatus.PARTIAL_SWEEP + migration AddPartialSweepToAccountStatus.
  • SweepResult gains isPartial?: boolean and error?: string; txHash/timestamp become optional.
  • SweepExecutionRequest gains skipContractAuth?: boolean.
  • SweepsService.executeSweep no longer throws on Horizon payment failure — it returns {success: false, isPartial: true, error, contractAuthHash}. When skipContractAuth=true, steps 2-3 (auth + execute_sweep) are skipped and only the Horizon payment runs.
  • ClaimRedemptionProvider captures wasPartialOnEntry from the SELECT FOR UPDATE tx, accepts PARTIAL_SWEEP as a re-entrable state, threads skipContractAuth through, persists the new partial sweep path (accountsRepository.update + audit outcome=partial + sweep.partial webhook), and the catch-block revert now preserves PARTIAL_SWEEP when the entry state was partial.
  • Tests cover: success path with skipContractAuth x3, isPartial x2 sweeps + 4 new partial-sweep cases in the orchestrator.

Testing

  • npx tsc --noEmit clean on all touched dirs.
  • npx prettier --check clean on all touched files.
  • claim-redemption.provider.spec.ts: 20/20 passing
  • sweeps.service.spec.ts: 17/17 passing

Operational notes

  • Operators receive a new sweep.partial webhook event with {accountId, amount, asset, destination, error, contractAuthHash} so they can decide whether to retry, refund, or escalate.
  • Clients receive a structured HTTP 200 with {success: false, isPartial: true, error, contractAuthHash} so callers can distinguish recoverable partial failure from a genuine error.
  • Retries are gated by SELECT FOR UPDATE pessimistic_write; Stellar sequence numbers are monotonic so double-payment is impossible.
  • The new PARTIAL_SWEEP DB status is sticky: a re-submit on the same token stays in PARTIAL_SWEEP until the Horizon payment succeeds (or an operator intervenes).

Closes #169
Closes #172
Closes #173

…gelet-org#169)

When the SweepController contract execute_sweep() succeeds but the
subsequent Horizon Operation.payment fails, the contract is left in
Swept state with no funds moved. Previously this was logged as a
CRITICAL error and required manual recovery. Now:

- SweepsService.executeSweep catches the Horizon payment failure and
  returns a structured partial result { isPartial: true, error }
  instead of throwing.
- ClaimRedemptionProvider accepts PARTIAL_SWEEP as a re-entrable
  state, transitions the account to it on a partial outcome, records
  outcome=partial on the audit log, and fires a sweep.partial
  webhook.
- A subsequent redemption attempt on a PARTIAL_SWEEP account passes
  skipContractAuth=true so the contract is NOT re-invoked (it is
  already in Swept state and re-invocation would revert); only the
  Horizon payment is retried. Stellar sequence numbers protect against
  duplicate payment on retry.
- New migration adds the partial_sweep value to the
  account_status_enum PostgreSQL enum.

Closes bridgelet-org#169
@drips-wave

drips-wave Bot commented Jun 27, 2026

Copy link
Copy Markdown

@dzekojohn4 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

@phertyameen

Copy link
Copy Markdown
Contributor

@dzekojohn4 YOur CI is failing and resolve conflict along with the CI please

- Add 'partial' outcome to ClaimOutcome union so audit logs can record
  the new contract-authorized-but-payment-failed outcome.
- Update sweep spec to remove the obsolete 'propagates
  TransactionProvider errors' assertion; the sweeper now returns a
  structured isPartial result instead of throwing.
- Reset mutable fields on the shared mockAccount in
  claim-redemption.spec so prior test mutations do not leak, and fix
  the failing partial-redemption test (mock a partial sweep result
  rather than the full success result).
- Bump expected migration count to 9 and register the new
  AddPartialSweepToAccountStatus migration in the integration runner.
@phertyameen

Copy link
Copy Markdown
Contributor

@dzekojohn4 Can you please resolve conflict?

…elet-org#167 and bridgelet-org#169 blocks)

Three-way merge with upstream/main (7ee4689) flagged a single content
conflict in src/modules/claims/providers/claim-redemption.provider.spec.ts.
Both sides added a new top-level describe('\''redeemClaim - ...'\'''') block
at the same insertion point (right after the existing sweep-failure
block):

- ours (issue bridgelet-org#169) added '\''redeemClaim - partial sweep path'\'' with six
  tests covering the new PARTIAL_SWEEP entry + isPartial result +
  audit/webhook/return shape + revert-on-throw behavior.
- theirs (issue bridgelet-org#167) added '\''redeemClaim - propagates TokenVerification
  errors'\'' with three tests asserting BadRequestException propagation
  from verifyClaimToken.

Resolution: keep both describe blocks. Order is theirs (bridgelet-org#167) before ours
(bridgelet-org#169) so error-propagation tests precede partial-sweep behavior, matching
the rough chronological feature order.

Also preserved from this branch:
  - beforeEach now resets mockAccount.{status, destinationAddress,
    claimedAt} to avoid leakage between tests where the provider mutates
    the locked record in-place.
  - The success-redemption toHaveBeenCalledWith matcher now includes
    skipContractAuth: false explicitly so the test breaks loudly if the
    provider ever stops passing that flag on a fresh attempt.

Verification:
  - brace balance: 109 open / 109 close.
  - jest on this spec: all 23 tests pass (3 new from bridgelet-org#167 + 6 new from
    bridgelet-org#169 + 14 prior).
  - tsc --noEmit on this file: clean.

Resolves the merge conflict on PR bridgelet-org#268.
@phertyameen phertyameen merged commit 705a192 into bridgelet-org:main Jul 1, 2026
2 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

3 participants