Skip to content
Closed
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
12 changes: 12 additions & 0 deletions app/backend/src/dto/link/link-metadata-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ export class LinkMetadataResponseDto {
})
swapOptions?: PathPreviewRow[] | null;

@ApiPropertyOptional({
description: 'Short-lived payment token (replaces canonical params for secure links)',
example: 'qt_2xK9mP4rT8wZ7vN1qL5bR3cJ6',
})
token?: string | null;

@ApiPropertyOptional({
description: 'Token expiry timestamp',
example: '2026-08-28T12:00:00.000Z',
})
tokenExpiresAt?: string | null;

@ApiProperty({
description: 'Metadata information',
example: {
Expand Down
135 changes: 135 additions & 0 deletions app/backend/src/dto/payment-token/payment-token-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class PaymentTokenResponseDto {
@ApiProperty({
description: 'The payment token value',
example: 'qt_2xK9mP4rT8wZ7vN1qL5bR3cJ6',
})
token!: string;

@ApiProperty({
description: 'Token expiry timestamp',
example: '2026-08-28T12:00:00.000Z',
})
expiresAt!: string;

@ApiProperty({
description: 'Token status',
example: 'active',
enum: ['active', 'consumed', 'revoked', 'expired'],
})
status!: string;

@ApiPropertyOptional({
description: 'Canonical payment params (for legacy fallback)',
example: 'amount=50.5000000&asset=XLM&username=john_doe',
})
canonical?: string;
}

export class PaymentTokenResolveDto {
@ApiProperty({
description: 'Resolved payment context from token',
})
paymentContext!: {
amount: string;
asset: string;
username?: string | null;
destination?: string | null;
memo?: string | null;
expiresAt?: string | null;
};

@ApiProperty({
description: 'Token status',
example: 'active',
})
status!: string;
}

export class PaymentTokenRotateDto {
@ApiProperty({
description: 'New token value after rotation',
example: 'qt_9sH2kL5pR7wZ4vN8qM1bT6cJ3',
})
newToken!: string;

@ApiProperty({
description: 'New token expiry timestamp',
example: '2026-08-28T12:00:00.000Z',
})
expiresAt!: string;

@ApiProperty({
description: 'Updated canonical params with new token',
example: 'token=qt_9sH2kL5pR7wZ4vN8qM1bT6cJ3',
})
canonical!: string;
}

export class PaymentTokenRevokeResponseDto {
@ApiProperty({
description: 'Whether revocation succeeded',
example: true,
})
success!: boolean;

@ApiProperty({
description: 'Revoked token',
example: 'qt_2xK9mP4rT8wZ7vN1qL5bR3cJ6',
})
token!: string;
}

export class PaymentTokenGenerateRequestDto {
@ApiProperty({
description: 'Payment amount',
example: 50.5,
})
amount!: number;

@ApiPropertyOptional({
description: 'Asset code',
example: 'XLM',
default: 'XLM',
})
asset?: string;

@ApiPropertyOptional({
description: 'QuickEx username (payee)',
example: 'john_doe',
})
username?: string;

@ApiPropertyOptional({
description: 'Destination Stellar public key',
example: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890',
})
destination?: string;

@ApiPropertyOptional({
description: 'Payment memo',
example: 'Invoice #123',
})
memo?: string;

@ApiPropertyOptional({
description: 'Memo type',
example: 'text',
default: 'text',
})
memoType?: string;

@ApiPropertyOptional({
description: 'Token TTL in seconds (default 86400 = 24h)',
example: 3600,
default: 86400,
})
ttlSeconds?: number;

@ApiPropertyOptional({
description: 'Accepted asset codes for multi-asset support',
example: ['XLM', 'USDC'],
})
acceptedAssets?: string[];
}
18 changes: 14 additions & 4 deletions app/backend/src/links/bulk-payment-links.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from './dto/bulk-payment-link.dto';
import { LinkMetadataRequestDto } from '../dto';
import { v4 as uuidv4 } from 'uuid';
import { PaymentTokenService } from './payment-token.service';

@Injectable()
export class BulkPaymentLinksService {
Expand All @@ -17,6 +18,7 @@ export class BulkPaymentLinksService {
constructor(
private readonly linksService: LinksService,
private readonly featureFlagsService: FeatureFlagsService,
private readonly paymentTokenService: PaymentTokenService,
) {}

/**
Expand Down Expand Up @@ -149,13 +151,21 @@ export class BulkPaymentLinksService {
// Generate unique ID
const id = `link_${uuidv4().substring(0, 12)}`;

// Build shareable URL
const url = `https://app.quickex.to/pay?${metadata.canonical}`;
// Use short-lived token when available, fall back to canonical params
let shareableUrl: string;
let canonicalForm = metadata.canonical;

if (metadata.token) {
shareableUrl = `https://app.quickex.to/pay?token=${metadata.token}`;
canonicalForm = `token=${metadata.token}`;
} else {
shareableUrl = `https://app.quickex.to/pay?${metadata.canonical}`;
}

return {
id,
canonical: metadata.canonical,
url,
canonical: canonicalForm,
url: shareableUrl,
amount: metadata.amount,
asset: metadata.asset,
username: metadata.username || undefined,
Expand Down
5 changes: 5 additions & 0 deletions app/backend/src/links/links.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { RecurringPaymentProcessor } from "../stellar/recurring-payment-processo
import { PaymentLinkController } from "./payment-link.controller";
import { PaymentLinkService } from "./payment-link.service";
import { PaymentLinkExpiryService } from './payment-link-expiry.service';
import { PaymentTokenController } from './payment-token.controller';
import { PaymentTokenService } from './payment-token.service';
import { SupabaseModule } from "../supabase/supabase.module";
import { StellarModule } from "../stellar/stellar.module";
import { ApiKeysModule } from "../api-keys/api-keys.module";
Expand All @@ -26,6 +28,7 @@ import { AuditModule } from "../audit/audit.module";
BulkPaymentLinksController,
RecurringPaymentsController,
PaymentLinkController,
PaymentTokenController,
],
providers: [
LinksService,
Expand All @@ -36,6 +39,7 @@ import { AuditModule } from "../audit/audit.module";
RecurringPaymentsRepository,
RecurringPaymentProcessor,
PaymentLinkService,
PaymentTokenService,
],
exports: [
LinksService,
Expand All @@ -45,6 +49,7 @@ import { AuditModule } from "../audit/audit.module";
RecurringPaymentsRepository,
RecurringPaymentProcessor,
PaymentLinkService,
PaymentTokenService,
],
imports: [
SupabaseModule,
Expand Down
27 changes: 27 additions & 0 deletions app/backend/src/links/links.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type PathPreviewRow,
} from '../stellar/path-preview.service';
import { PrivacyService } from '../privacy/privacy.service';
import { PaymentTokenService } from './payment-token.service';

@Injectable()
export class LinksService {
Expand All @@ -15,6 +16,7 @@ export class LinksService {
constructor(
@Optional() private readonly pathPreviewService?: PathPreviewService,
@Optional() private readonly privacyService?: PrivacyService,
@Optional() private readonly paymentTokenService?: PaymentTokenService,
) {}

async generateMetadata(request: LinkMetadataRequestDto): Promise<LinkMetadataResponseDto> {
Expand Down Expand Up @@ -87,6 +89,29 @@ export class LinksService {
swapOptions = await this.buildSwapOptions(amt, normalizedAsset, acceptedAssets);
}

let token: string | undefined;
let tokenExpiresAt: string | undefined;
if (this.paymentTokenService) {
try {
const tokenResult = await this.paymentTokenService.generateToken({
amount: amt,
assetCode: normalizedAsset,
username: username ?? undefined,
destinationPublicKey: destination ?? undefined,
memo: memo ?? undefined,
memoType,
acceptedAssets,
ttlSeconds: expiresAt
? Math.max(3600, Math.floor((expiresAt.getTime() - Date.now()) / 1000))
: undefined,
});
token = tokenResult.token;
tokenExpiresAt = tokenResult.expiresAt;
} catch {
this.logger.warn('Payment token generation failed; falling back to canonical params');
}
}

return {
amount: amt,
memo,
Expand All @@ -95,6 +120,8 @@ export class LinksService {
privacy,
expiresAt,
canonical,
token,
tokenExpiresAt,
username,
destination,
referenceId,
Expand Down
16 changes: 13 additions & 3 deletions app/backend/src/links/payment-link-expiry.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
import { SupabaseService } from '../supabase/supabase.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AuditService } from '../audit/audit.service';
import { PaymentTokenService } from './payment-token.service';

@Injectable()
export class PaymentLinkExpiryService {
Expand All @@ -14,17 +15,26 @@ export class PaymentLinkExpiryService {
private readonly supabase: SupabaseService,
private readonly eventEmitter: EventEmitter2,
private readonly auditService: AuditService,
private readonly paymentTokenService: PaymentTokenService,
) {}

// Run every minute to sweep expired open links. Idempotent by design.
// Run every minute to sweep expired open links and tokens. Idempotent by design.
@Cron(CronExpression.EVERY_MINUTE, { name: 'payment-link-expiry-sweep', timeZone: 'UTC' })
async handleCron(): Promise<void> {
const runId = uuidv4();
try {
const count = await this.runExpirySweep(runId);
if (count > 0) this.logger.log(`Expiry sweep ${runId}: expired ${count} link(s)`);
if (count > 0) this.logger.log(`Expiry sweep: expired ${count} link(s)`);
} catch (err) {
this.logger.error(`Expiry sweep ${runId} failed: ${(err as Error).message}`);
this.logger.error(`Expiry sweep failed: ${(err as Error).message}`);
}

// Also sweep expired payment tokens
try {
const tokenCount = await this.paymentTokenService.sweepExpiredTokens();
if (tokenCount > 0) this.logger.log(`Token sweep: expired ${tokenCount} token(s)`);
} catch (err) {
this.logger.error(`Token sweep failed: ${(err as Error).message}`);
}
}

Expand Down
2 changes: 1 addition & 1 deletion app/backend/src/links/payment-link.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export class PaymentLinkService {

return null;
} catch (error) {
this.logger.error(`Failed to check payment status: ${error}`);
this.logger.error('Failed to check payment status');
// If we can't check Horizon, assume payment not made
return null;
}
Expand Down
Loading
Loading