Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/database/migrations.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
await queryRunner.query(`
ALTER TYPE "public"."account_status_enum"
ADD VALUE 'partial_sweep' AFTER 'claiming'
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// 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'`,
);
}
}
1 change: 1 addition & 0 deletions src/modules/accounts/enums/account-status.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
48 changes: 44 additions & 4 deletions src/modules/claims/dto/claim-redemption-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -34,15 +36,53 @@ 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',
required: false,
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;
}
2 changes: 1 addition & 1 deletion src/modules/claims/entities/claim-audit-log.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
183 changes: 183 additions & 0 deletions src/modules/claims/providers/claim-redemption.provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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,
});
});

Expand Down Expand Up @@ -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<unknown>) =>
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<unknown>) =>
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<unknown>) =>
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<unknown>) =>
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 }),
);
});
});
});
Loading
Loading