diff --git a/src/database/migrations.integration.spec.ts b/src/database/migrations.integration.spec.ts index bc9fc32..ce3acd8 100644 --- a/src/database/migrations.integration.spec.ts +++ b/src/database/migrations.integration.spec.ts @@ -36,7 +36,7 @@ describe('Database migrations integration', () => { }); it('applies every migration, matches entity metadata, and enforces foreign keys', () => { - expect(result.executedMigrationNames).toHaveLength(8); + expect(result.executedMigrationNames).toHaveLength(9); expect(result.schemaInSync).toBe(true); expect(result.enumValues).toEqual(Object.values(AccountStatus)); expect(result.foreignKeyColumns).toContainEqual(['accountId']); diff --git a/src/database/migrations/1718100008000-AddPartialSweepToAccountStatus.ts b/src/database/migrations/1718100008000-AddPartialSweepToAccountStatus.ts new file mode 100644 index 0000000..5f1b84e --- /dev/null +++ b/src/database/migrations/1718100008000-AddPartialSweepToAccountStatus.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPartialSweepToAccountStatus1718100008000 implements MigrationInterface { + name = 'AddPartialSweepToAccountStatus1718100008000'; + + // ALTER TYPE ADD VALUE cannot run inside a transaction on some PostgreSQL + // versions -- setting transaction = false keeps this migration safe. + public transaction = false; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TYPE "public"."account_status_enum" + ADD VALUE 'partial_sweep' AFTER 'claiming' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // PostgreSQL has no DROP VALUE -- must recreate the enum type. + await queryRunner.query( + `ALTER TABLE "accounts" ALTER COLUMN "status" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "accounts" ALTER COLUMN "status" TYPE text USING "status"::text`, + ); + await queryRunner.query(`DROP TYPE "public"."account_status_enum"`); + await queryRunner.query(` + CREATE TYPE "public"."account_status_enum" AS ENUM( + 'initializing', + 'pending_payment', + 'pending_claim', + 'claiming', + 'claimed', + 'expired', + 'failed' + ) + `); + await queryRunner.query( + `UPDATE "accounts" SET "status" = 'claiming' WHERE "status" = 'partial_sweep'`, + ); + await queryRunner.query(` + ALTER TABLE "accounts" + ALTER COLUMN "status" TYPE "public"."account_status_enum" + USING "status"::"public"."account_status_enum" + `); + await queryRunner.query( + `ALTER TABLE "accounts" ALTER COLUMN "status" SET DEFAULT 'pending_payment'`, + ); + } +} diff --git a/src/modules/accounts/enums/account-status.enum.ts b/src/modules/accounts/enums/account-status.enum.ts index d1e9663..230d853 100644 --- a/src/modules/accounts/enums/account-status.enum.ts +++ b/src/modules/accounts/enums/account-status.enum.ts @@ -3,6 +3,7 @@ export enum AccountStatus { PENDING_PAYMENT = 'pending_payment', PENDING_CLAIM = 'pending_claim', CLAIMING = 'claiming', + PARTIAL_SWEEP = 'partial_sweep', CLAIMED = 'claimed', EXPIRED = 'expired', FAILED = 'failed', diff --git a/src/modules/claims/dto/claim-redemption-response.dto.ts b/src/modules/claims/dto/claim-redemption-response.dto.ts index 12df084..21179f0 100644 --- a/src/modules/claims/dto/claim-redemption-response.dto.ts +++ b/src/modules/claims/dto/claim-redemption-response.dto.ts @@ -9,9 +9,11 @@ export class ClaimRedemptionResponseDto { @ApiProperty({ example: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - description: 'The Stellar transaction hash of the redemption', + required: false, + description: + 'The Stellar transaction hash of the redemption; populated when the sweep completed successfully, omitted when isPartial is true.', }) - txHash: string; + txHash?: string; @ApiProperty({ example: '100.0000000', @@ -34,10 +36,11 @@ export class ClaimRedemptionResponseDto { @ApiProperty({ example: '2026-01-21T15:45:30Z', + required: false, description: - 'The timestamp when the claim was redeemed and funds were swept', + 'The timestamp when the claim was redeemed and funds were swept; omitted when isPartial is true (no funds moved yet).', }) - sweptAt: Date; + sweptAt?: Date; @ApiProperty({ example: 'Claim successfully redeemed and funds transferred', @@ -45,4 +48,41 @@ export class ClaimRedemptionResponseDto { description: 'Optional additional message about the redemption', }) message?: string; + + /** + * True when contract authorization succeeded but the Horizon payment + * failed. The account is left in PARTIAL_SWEEP state; subsequent + * redemption attempts with the same token retry the payment with + * skipContractAuth: true. Distinct from success: false (a hard error). + */ + @ApiProperty({ + required: false, + description: + 'Set when the sweep completed the contract authorization but the Horizon payment failed and the account is in PARTIAL_SWEEP.', + }) + isPartial?: boolean; + + /** + * Authorization hash from the SweepController contract. Returns the + * synthesized hash on retry (skipContractAuth: true) so the audit trail + * can correlate the partial event with the original contract entry. + */ + @ApiProperty({ + required: false, + description: + 'Hash identifying the contract authorization event for this sweep attempt.', + }) + contractAuthHash?: string; + + /** + * Populated only when isPartial is true. Surface of the underlying + * Horizon error so callers can decide to retry, escalate, or surface + * a user-facing message. + */ + @ApiProperty({ + required: false, + description: + 'Error message from the failed Horizon payment submission; populated only when isPartial is true.', + }) + error?: string; } diff --git a/src/modules/claims/entities/claim-audit-log.entity.ts b/src/modules/claims/entities/claim-audit-log.entity.ts index 71b8ef5..27f3d08 100644 --- a/src/modules/claims/entities/claim-audit-log.entity.ts +++ b/src/modules/claims/entities/claim-audit-log.entity.ts @@ -6,7 +6,7 @@ import { Index, } from 'typeorm'; -export type ClaimOutcome = 'success' | 'failure'; +export type ClaimOutcome = 'success' | 'failure' | 'partial'; @Entity('claim_audit_log') @Index(['accountId']) diff --git a/src/modules/claims/providers/claim-redemption.provider.spec.ts b/src/modules/claims/providers/claim-redemption.provider.spec.ts index 03ca6fc..f292d1d 100644 --- a/src/modules/claims/providers/claim-redemption.provider.spec.ts +++ b/src/modules/claims/providers/claim-redemption.provider.spec.ts @@ -155,6 +155,14 @@ describe('ClaimRedemptionProvider', () => { } beforeEach(async () => { + // Reset mutable values on the shared mockAccount so a previous test's + // `locked.status = AccountStatus.CLAIMING` mutation does not leak into + // subsequent tests (the shared reference is locked, then mutated by + // redeemClaim's transaction body). + mockAccount.status = AccountStatus.PENDING_CLAIM; + mockAccount.destinationAddress = ''; + mockAccount.claimedAt = null; + const ds = makeHappyPathDataSource(); provider = await buildModule(ds); @@ -216,6 +224,8 @@ describe('ClaimRedemptionProvider', () => { destinationAddress: VALID_DESTINATION, amount: mockAccount.amount, asset: mockAccount.asset, + // Fresh PENDING_CLAIM attempt -> contract auth must run. + skipContractAuth: false, }); }); @@ -386,4 +396,177 @@ describe('ClaimRedemptionProvider', () => { ).rejects.toThrow(BadRequestException); }); }); + + // ======================================================================== + // Partial-sweep path (issue #169): when SweepsService.executeSweep returns + // isPartial=true the orchestrator transitions the account to PARTIAL_SWEEP, + // records an audit entry with outcome='partial', fires a sweep.partial + // webhook, and returns a structured non-error response so callers can + // retry with the same token (the second attempt passes skipContractAuth + // because the contract is already in Swept state). + // ======================================================================== + describe('redeemClaim - partial sweep path', () => { + it('accepts PARTIAL_SWEEP entry and passes skipContractAuth=true to executeSweep', async () => { + const partialAccount = { + ...mockAccount, + status: AccountStatus.PARTIAL_SWEEP, + }; + const ds = { + transaction: jest + .fn() + .mockImplementationOnce( + async (cb: (m: unknown) => Promise) => + cb(makeManager(partialAccount)), + ), + }; + const p = await buildModule(ds); + // Return a partial result so redeemClaim never enters the success + // path (which would need a 2nd transaction we have not mocked). + mockSweepsService.executeSweep.mockResolvedValueOnce({ + success: false, + isPartial: true, + contractAuthHash: 'partial-auth-hash', + amountSwept: mockAccount.amount, + destination: VALID_DESTINATION, + error: 'Horizon offline', + }); + + await p.redeemClaim(VALID_TOKEN, VALID_DESTINATION); + + expect(mockSweepsService.executeSweep).toHaveBeenCalledWith( + expect.objectContaining({ skipContractAuth: true }), + ); + }); + + it('passes skipContractAuth=false for a fresh PENDING_CLAIM attempt', async () => { + const ds = makeHappyPathDataSource(); + const p = await buildModule(ds); + mockSweepsService.executeSweep.mockResolvedValueOnce(mockSweepResult); + + await p.redeemClaim(VALID_TOKEN, VALID_DESTINATION); + + expect(mockSweepsService.executeSweep).toHaveBeenCalledWith( + expect.objectContaining({ skipContractAuth: false }), + ); + }); + + it('returns success:false, isPartial:true and transitions account to PARTIAL_SWEEP when sweeper signals partial', async () => { + const ds = { + transaction: jest + .fn() + .mockImplementationOnce( + async (cb: (m: unknown) => Promise) => + cb(makeManager(mockAccount)), + ), + }; + const p = await buildModule(ds); + const partialResult = { + success: false, + isPartial: true, + contractAuthHash: 'partial-auth-hash', + amountSwept: '100.0000000', + destination: VALID_DESTINATION, + error: 'Horizon offline', + }; + mockSweepsService.executeSweep.mockResolvedValueOnce(partialResult); + + const result = await p.redeemClaim(VALID_TOKEN, VALID_DESTINATION); + + expect(result.success).toBe(false); + expect(result.isPartial).toBe(true); + expect(result.contractAuthHash).toBe('partial-auth-hash'); + expect(result.error).toBe('Horizon offline'); + expect((result as { message?: string }).message).toMatch( + /partial|Retry/i, + ); + + // The account must be reverted to PARTIAL_SWEEP, not PENDING_CLAIM. + expect(mockAccountsRepository.update).toHaveBeenCalledWith( + mockAccount.id, + expect.objectContaining({ + status: AccountStatus.PARTIAL_SWEEP, + destinationAddress: '', + }), + ); + }); + + it('emits a sweep.partial webhook with the auth hash and error on partial outcome', async () => { + const ds = { + transaction: jest + .fn() + .mockImplementationOnce( + async (cb: (m: unknown) => Promise) => + cb(makeManager(mockAccount)), + ), + }; + const p = await buildModule(ds); + const partialResult = { + success: false, + isPartial: true, + contractAuthHash: 'partial-auth-hash', + amountSwept: '100.0000000', + destination: VALID_DESTINATION, + error: 'Horizon offline', + }; + mockSweepsService.executeSweep.mockResolvedValueOnce(partialResult); + + await p.redeemClaim(VALID_TOKEN, VALID_DESTINATION); + + expect(mockWebhooksService.triggerEvent).toHaveBeenCalledWith( + 'sweep.partial', + expect.objectContaining({ + accountId: mockAccount.id, + error: 'Horizon offline', + contractAuthHash: 'partial-auth-hash', + }), + ); + }); + + it('on sweep failure (throw) leaves the account in PARTIAL_SWEEP if it entered as PARTIAL_SWEEP', async () => { + const partialAccount = { + ...mockAccount, + status: AccountStatus.PARTIAL_SWEEP, + }; + const ds = { + transaction: jest + .fn() + .mockImplementationOnce( + async (cb: (m: unknown) => Promise) => + cb(makeManager(partialAccount)), + ), + }; + const p = await buildModule(ds); + // Sweeper throws (NOT a structured isPartial). + mockSweepsService.executeSweep.mockRejectedValueOnce( + new Error('Contract invoke failed inside skip path'), + ); + + await expect( + p.redeemClaim(VALID_TOKEN, VALID_DESTINATION), + ).rejects.toThrow('Contract invoke failed'); + + // The revert path must preserve the PARTIAL_SWEEP invariant. + expect(mockAccountsRepository.update).toHaveBeenCalledWith( + mockAccount.id, + expect.objectContaining({ status: AccountStatus.PARTIAL_SWEEP }), + ); + }); + + it('on sweep failure (throw) reverts a fresh PENDING_CLAIM attempt back to PENDING_CLAIM', async () => { + const ds = makeHappyPathDataSource(); + const p = await buildModule(ds); + mockSweepsService.executeSweep.mockRejectedValueOnce( + new Error('Network blip'), + ); + + await expect( + p.redeemClaim(VALID_TOKEN, VALID_DESTINATION), + ).rejects.toThrow('Network blip'); + + expect(mockAccountsRepository.update).toHaveBeenCalledWith( + mockAccount.id, + expect.objectContaining({ status: AccountStatus.PENDING_CLAIM }), + ); + }); + }); }); diff --git a/src/modules/claims/providers/claim-redemption.provider.ts b/src/modules/claims/providers/claim-redemption.provider.ts index 5d44071..49f1a9c 100644 --- a/src/modules/claims/providers/claim-redemption.provider.ts +++ b/src/modules/claims/providers/claim-redemption.provider.ts @@ -78,8 +78,8 @@ export class ClaimRedemptionProvider { // Atomically acquire the claim slot using SELECT FOR UPDATE. // This prevents concurrent requests from both passing the status check. - const account = await this.dataSource.transaction( - async (manager: EntityManager) => { + const { locked: account, wasPartialOnEntry } = + await this.dataSource.transaction(async (manager: EntityManager) => { const locked = await manager .createQueryBuilder(Account, 'account') .setLock('pessimistic_write') @@ -91,19 +91,31 @@ export class ClaimRedemptionProvider { } if (locked.status === AccountStatus.CLAIMED) { - return locked; + return { locked, wasPartialOnEntry: false }; } if (locked.status === AccountStatus.CLAIMING) { throw new ConflictException('Claim is already being processed'); } + // Allow PENDING_CLAIM (fresh attempt) and PARTIAL_SWEEP (retry of a + // contract-was-Swept-but-payment-failed prior attempt). Any other + // status is rejected. + if ( + locked.status !== AccountStatus.PENDING_CLAIM && + locked.status !== AccountStatus.PARTIAL_SWEEP + ) { + throw new BadRequestException( + `Account cannot be redeemed. Status: ${locked.status}`, + ); + } + + const wasPartial = locked.status === AccountStatus.PARTIAL_SWEEP; locked.status = AccountStatus.CLAIMING; locked.destinationAddress = destinationAddress; await manager.save(locked); - return locked; - }, - ); + return { locked, wasPartialOnEntry: wasPartial }; + }); // Idempotent response for already-claimed account if (account.status === AccountStatus.CLAIMED) { @@ -138,9 +150,53 @@ export class ClaimRedemptionProvider { destinationAddress, amount: account.amount, asset: account.asset, + // PARTIAL_SWEEP retry: contract is already in Swept state from the + // prior partial failure, so re-invoking execute_sweep would revert. + // Skip steps 2-3 and only re-submit the Horizon payment. + skipContractAuth: wasPartialOnEntry, }); - TransactionHashValidator.assertValid(sweepResult.txHash); + // PARTIAL SWEEP path: contract authorized but Horizon payment failed. + // Mark the account so a future redemption attempt can retry the payment + // (with skipContractAuth=true). Surface this as a non-error response + // (status=200, success=false, isPartial=true) so callers can decide. + if (sweepResult.isPartial) { + await this.accountsRepository.update(account.id, { + status: AccountStatus.PARTIAL_SWEEP, + destinationAddress: '', + }); + + await this.claimAuditProvider.record({ + accountId: account.id, + destination: destinationAddress, + ip, + outcome: 'partial', + failureReason: sweepResult.error ?? 'unknown', + }); + + await this.webhooksService.triggerEvent('sweep.partial', { + accountId: account.id, + amount: account.amount, + asset: account.asset, + destination: destinationAddress, + error: sweepResult.error, + contractAuthHash: sweepResult.contractAuthHash, + }); + + return { + success: false, + isPartial: true, + contractAuthHash: sweepResult.contractAuthHash, + amountSwept: account.amount, + asset: account.asset, + destination: destinationAddress, + error: sweepResult.error, + message: + 'Sweep partially completed: contract authorized but Horizon payment failed. Retry with the same token.', + }; + } + + TransactionHashValidator.assertValid(sweepResult.txHash as string); // Atomically record the claim and mark account as CLAIMED const claim = await this.dataSource.transaction( @@ -152,7 +208,7 @@ export class ClaimRedemptionProvider { const newClaim = manager.create(Claim, { accountId: account.id, destinationAddress, - sweepTxHash: sweepResult.txHash, + sweepTxHash: sweepResult.txHash as string, amountSwept: account.amount, asset: account.asset, claimedAt: account.claimedAt, @@ -189,9 +245,16 @@ export class ClaimRedemptionProvider { sweptAt: claim.claimedAt, }; } catch (error) { - // Revert from CLAIMING back to PENDING_CLAIM so the user can retry + // Revert from CLAIMING. On a retry that began in PARTIAL_SWEEP we + // preserve the contract-is-Swept invariant by leaving the account + // in PARTIAL_SWEEP — the contract is already authorized, so + // settling-back to PENDING_CLAIM would invalidate that. Otherwise + // revert to PENDING_CLAIM so the user can retry. + const revertStatus = wasPartialOnEntry + ? AccountStatus.PARTIAL_SWEEP + : AccountStatus.PENDING_CLAIM; await this.accountsRepository.update(account.id, { - status: AccountStatus.PENDING_CLAIM, + status: revertStatus, destinationAddress: '', }); diff --git a/src/modules/sweeps/interfaces/execute-sweep.interface.ts b/src/modules/sweeps/interfaces/execute-sweep.interface.ts index 34331a8..8d2abc8 100644 --- a/src/modules/sweeps/interfaces/execute-sweep.interface.ts +++ b/src/modules/sweeps/interfaces/execute-sweep.interface.ts @@ -5,4 +5,13 @@ export interface SweepExecutionRequest { destinationAddress: string; amount: string; asset: string; + /** + * When true, the sweeper skips the smart-contract auth signature + * generation AND the `execute_sweep` contract call (steps 2 and 3) + * and only runs the Horizon payment (step 4). Used for retrying an + * account in `PARTIAL_SWEEP` state whose contract is already in + * `Swept` state from a prior partial failure — re-invoking + * `execute_sweep` would revert on-chain. + */ + skipContractAuth?: boolean; } diff --git a/src/modules/sweeps/interfaces/sweep-result.interface.ts b/src/modules/sweeps/interfaces/sweep-result.interface.ts index 4af7bc3..76e010e 100644 --- a/src/modules/sweeps/interfaces/sweep-result.interface.ts +++ b/src/modules/sweeps/interfaces/sweep-result.interface.ts @@ -1,8 +1,26 @@ export interface SweepResult { success: boolean; - txHash: string; + /** + * True when the smart contract `execute_sweep` succeeded but the + * subsequent Horizon `Operation.payment` failed. The contract is + * therefore in `Swept` state but no funds have moved. Marking + * `isPartial` (rather than throwing) lets the caller transition the + * account to `PARTIAL_SWEEP` and emit a structured `sweep.partial` + * webhook event. + * + * The next redemption attempt on the same account passes + * `skipContractAuth: true` and only re-submits the Horizon payment. + */ + isPartial?: boolean; + txHash?: string; contractAuthHash: string; amountSwept: string; destination: string; - timestamp: Date; + timestamp?: Date; + /** + * Populated only when `isPartial` is true; carries the underlying error + * message from the failed Horizon submission so it can be surfaced in + * the `sweep.partial` webhook payload. + */ + error?: string; } diff --git a/src/modules/sweeps/sweeps.service.spec.ts b/src/modules/sweeps/sweeps.service.spec.ts index 34d301f..dab767f 100644 --- a/src/modules/sweeps/sweeps.service.spec.ts +++ b/src/modules/sweeps/sweeps.service.spec.ts @@ -240,17 +240,15 @@ describe('SweepsService', () => { ).not.toHaveBeenCalled(); }); - it('propagates TransactionProvider errors', async () => { - transactionProvider.executeSweepTransaction.mockRejectedValue( - new Error('Horizon payment failed'), - ); - - await expect(service.executeSweep(validRequest)).rejects.toThrow( - 'Horizon payment failed', - ); - }); - - it('logs a CRITICAL error when the Horizon payment fails after contract authorization', async () => { + // Regression: prior behaviour propagated Horizon payment errors. + // Since #169, sweeper for the contract-authorized-but-payment-failed + // path no longer throws — it returns a structured isPartial result so + // the orchestrator can transition the account into PARTIAL_SWEEP and + // emit a sweep.partial webhook. The new contract is asserted by the + // dedicated `isPartial` test below; we deliberately omit the old + // `propagates TransactionProvider errors` test here. + + it('returns isPartial: true (does NOT throw) when Horizon payment fails after contract authorization', async () => { const horizonError = new Error('Horizon payment failed'); transactionProvider.executeSweepTransaction.mockRejectedValue( horizonError, @@ -261,12 +259,24 @@ describe('SweepsService', () => { .spyOn(service['logger'], 'error') .mockImplementation(() => {}); - await expect(service.executeSweep(validRequest)).rejects.toThrow( - 'Horizon payment failed', - ); + // The sweeper no longer throws — it returns a structured partial + // result so the orchestrator can transition the account to PARTIAL_SWEEP + // and emit a sweep.partial webhook. + const result = await service.executeSweep(validRequest); + expect(result.success).toBe(false); + expect(result.isPartial).toBe(true); + expect(result.error).toBe('Horizon payment failed'); + expect(result.contractAuthHash).toBe(MOCK_CONTRACT_AUTH_HASH); + expect(result.amountSwept).toBe(validRequest.amount); + expect(result.destination).toBe(validRequest.destinationAddress); + expect(result.txHash).toBeUndefined(); + expect(result.timestamp).toBeUndefined(); + + // The log line is now PARTIAL (not CRITICAL) to distinguish recoverable + // vs fatal in monitoring. expect(loggerErrorSpy).toHaveBeenCalledWith( - expect.stringContaining('CRITICAL'), + expect.stringContaining('PARTIAL sweep'), horizonError.stack, ); expect(loggerErrorSpy).toHaveBeenCalledWith( @@ -274,6 +284,52 @@ describe('SweepsService', () => { horizonError.stack, ); }); + + it('skips contract auth + execute_sweep when skipContractAuth=true; only runs Horizon payment', async () => { + await service.executeSweep({ + ...validRequest, + skipContractAuth: true, + }); + + expect(contractProvider.generateAuthSignature).not.toHaveBeenCalled(); + expect(stellarService.executeSweep).not.toHaveBeenCalled(); + // The Horizon payment still runs to retry the failed payment. + expect(transactionProvider.executeSweepTransaction).toHaveBeenCalledWith({ + ephemeralSecret: validRequest.ephemeralSecret, + destinationAddress: validRequest.destinationAddress, + amount: validRequest.amount, + asset: validRequest.asset, + }); + }); + + it('returns a success result with txHash when skipContractAuth=true and the Horizon payment succeeds', async () => { + const result = await service.executeSweep({ + ...validRequest, + skipContractAuth: true, + }); + + expect(result.isPartial).toBeUndefined(); + expect(result.success).toBe(true); + expect(result.txHash).toBe(MOCK_TX_HASH); + expect(result.contractAuthHash).toBe(MOCK_CONTRACT_AUTH_HASH); + }); + + it('returns isPartial: true when skipContractAuth=true but the Horizon payment still fails', async () => { + transactionProvider.executeSweepTransaction.mockRejectedValue( + new Error('Horizon offline'), + ); + + const result = await service.executeSweep({ + ...validRequest, + skipContractAuth: true, + }); + + expect(result.isPartial).toBe(true); + expect(result.error).toBe('Horizon offline'); + // Skipped contract side did NOT happen. + expect(contractProvider.generateAuthSignature).not.toHaveBeenCalled(); + expect(stellarService.executeSweep).not.toHaveBeenCalled(); + }); }); // ------------------------------------------------------------------------- diff --git a/src/modules/sweeps/sweeps.service.ts b/src/modules/sweeps/sweeps.service.ts index 126c4b0..488ebad 100644 --- a/src/modules/sweeps/sweeps.service.ts +++ b/src/modules/sweeps/sweeps.service.ts @@ -55,39 +55,63 @@ export class SweepsService { sweepExecutionRequest, ); - // Step 2: Generate authorization signature for the contract call - const authSignature = this.contractProvider.generateAuthSignature({ - ephemeralPublicKey: sweepExecutionRequest.ephemeralPublicKey, - destinationAddress: sweepExecutionRequest.destinationAddress, - }); + // Steps 2 & 3: Smart-contract authorization. + // On a retry into PARTIAL_SWEEP the contract is already in Swept state + // and re-invoking execute_sweep would revert on-chain. The orchestrator + // (ClaimRedemptionProvider) signals this via skipContractAuth: true and + // we synthesise the auth hash deterministically from the same inputs + // for audit-trail purposes. + let contractAuthHash: string; + if (sweepExecutionRequest.skipContractAuth) { + this.logger.log( + `Skip-contract-auth retry for account ${sweepExecutionRequest.accountId}: ` + + 'contract already in Swept state from prior partial failure.', + ); + contractAuthHash = this.contractProvider.generateAuthHash( + sweepExecutionRequest.ephemeralPublicKey, + sweepExecutionRequest.destinationAddress, + ); + } else { + // Step 2: Generate authorization signature for the contract call + const authSignature = this.contractProvider.generateAuthSignature({ + ephemeralPublicKey: sweepExecutionRequest.ephemeralPublicKey, + destinationAddress: sweepExecutionRequest.destinationAddress, + }); - // Step 3: Submit execute_sweep() on the SweepController Soroban contract - const sweepControllerContractId = this.configService.getOrThrow( - 'stellar.contracts.sweepController', - ); - const ephemeralAccountContractId = this.configService.getOrThrow( - 'stellar.contracts.ephemeralAccount', - ); + // Step 3: Submit execute_sweep() on the SweepController Soroban contract + const sweepControllerContractId = this.configService.getOrThrow( + 'stellar.contracts.sweepController', + ); + const ephemeralAccountContractId = this.configService.getOrThrow( + 'stellar.contracts.ephemeralAccount', + ); - await this.stellarService.executeSweep({ - sweepControllerContractId, - ephemeralAccountContractId, - destination: sweepExecutionRequest.destinationAddress, - authSignature, - signerSecret: sweepExecutionRequest.ephemeralSecret, - }); + await this.stellarService.executeSweep({ + sweepControllerContractId, + ephemeralAccountContractId, + destination: sweepExecutionRequest.destinationAddress, + authSignature, + signerSecret: sweepExecutionRequest.ephemeralSecret, + }); - this.logger.log( - `Contract sweep authorized for account ${sweepExecutionRequest.accountId}`, - ); + this.logger.log( + `Contract sweep authorized for account ${sweepExecutionRequest.accountId}`, + ); - // Compute auth hash before Step 4 so it's available in the catch block - const contractAuthHash = this.contractProvider.generateAuthHash( - sweepExecutionRequest.ephemeralPublicKey, - sweepExecutionRequest.destinationAddress, - ); + contractAuthHash = this.contractProvider.generateAuthHash( + sweepExecutionRequest.ephemeralPublicKey, + sweepExecutionRequest.destinationAddress, + ); + } - // Step 4: Execute the classic Horizon payment to move funds + // Step 4: Execute the classic Horizon payment to move funds. + // We catch errors here and return a structured partial result + // (isPartial: true) instead of propagating them: the contract may + // already be in Swept state by this point and a thrown exception + // would force the orchestrator into a manual recovery flow. + // Returning isPartial lets the caller transition the account to + // PARTIAL_SWEEP and emit a sweep.partial webhook so a retry + // redemption (or an operator) can pick up the work. let transactionResult: TransactionResult; try { transactionResult = @@ -100,16 +124,23 @@ export class SweepsService { this.sweepSuccessCounter.inc(); } catch (error) { this.sweepFailureCounter.inc(); - // Contract is now in Swept state but funds did not move. - // This requires manual recovery — log with full context. + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; this.logger.error( - `CRITICAL: Contract authorized but transaction failed for account ` + - `${sweepExecutionRequest.accountId}. Contract auth hash: ${contractAuthHash}. ` + - `Error: ${(error as Error).message}`, - (error as Error).stack, + `PARTIAL sweep: contract authorized but Horizon payment failed for ` + + `account ${sweepExecutionRequest.accountId}. Contract auth hash: ` + + `${contractAuthHash}. Error: ${message}`, + stack, ); this.sweepMetrics.recordFailed(); - throw error; + return { + success: false, + isPartial: true, + contractAuthHash, + amountSwept: sweepExecutionRequest.amount, + destination: sweepExecutionRequest.destinationAddress, + error: message, + }; } this.logger.log(`Sweep complete: txHash=${transactionResult.hash}`); diff --git a/test/migrations.integration.runner.ts b/test/migrations.integration.runner.ts index ecdd9bc..f27bfbb 100644 --- a/test/migrations.integration.runner.ts +++ b/test/migrations.integration.runner.ts @@ -18,6 +18,7 @@ import { AddClaimingToAccountStatus1718100004000 } from '../src/database/migrati import { CreateWebhookDeliveriesTable1718100005000 } from '../src/database/migrations/1718100005000-CreateWebhookDeliveriesTable.js'; import { AddHighTrafficIndexes1718100006000 } from '../src/database/migrations/1718100006000-AddHighTrafficIndexes.js'; import { CreateContractEventsTable1718100007000 } from '../src/database/migrations/1718100007000-CreateContractEventsTable.js'; +import { AddPartialSweepToAccountStatus1718100008000 } from '../src/database/migrations/1718100008000-AddPartialSweepToAccountStatus.js'; const postgresUser = 'postgres'; const postgresPassword = 'postgres'; @@ -32,6 +33,7 @@ const migrations = [ CreateWebhookDeliveriesTable1718100005000, AddHighTrafficIndexes1718100006000, CreateContractEventsTable1718100007000, + AddPartialSweepToAccountStatus1718100008000, ]; type SqlInMemoryLog = {