Skip to content

Commit cc738ee

Browse files
authored
feat(swap): add gasless transaction support for Swap flow (#2841)
- Add gaslessAvailable and eip7702Authorization fields to SwapPaymentInfoDto - Implement balance-check in toPaymentInfoDto() using PimlicoBundlerService - Add EIP-7702 authorization handling in confirmSwap() - Add unit tests for SellService and SwapService createDepositTx method
1 parent 7df3689 commit cc738ee

4 files changed

Lines changed: 399 additions & 3 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { createMock } from '@golevelup/ts-jest';
2+
import { Test, TestingModule } from '@nestjs/testing';
3+
import { PimlicoBundlerService } from 'src/integration/blockchain/shared/evm/paymaster/pimlico-bundler.service';
4+
import { PimlicoPaymasterService } from 'src/integration/blockchain/shared/evm/paymaster/pimlico-paymaster.service';
5+
import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service';
6+
import { CryptoService } from 'src/integration/blockchain/shared/services/crypto.service';
7+
import { AssetService } from 'src/shared/models/asset/asset.service';
8+
import { TestSharedModule } from 'src/shared/utils/test.shared.module';
9+
import { TestUtil } from 'src/shared/utils/test.util';
10+
import { RouteService } from 'src/subdomains/core/route/route.service';
11+
import { TransactionUtilService } from 'src/subdomains/core/transaction/transaction-util.service';
12+
import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service';
13+
import { UserService } from 'src/subdomains/generic/user/models/user/user.service';
14+
import { DepositService } from 'src/subdomains/supporting/address-pool/deposit/deposit.service';
15+
import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service';
16+
import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper';
17+
import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service';
18+
import { BuyCryptoWebhookService } from '../../../process/services/buy-crypto-webhook.service';
19+
import { BuyCryptoService } from '../../../process/services/buy-crypto.service';
20+
import { SwapRepository } from '../swap.repository';
21+
import { SwapService } from '../swap.service';
22+
23+
describe('SwapService', () => {
24+
let service: SwapService;
25+
26+
let swapRepo: SwapRepository;
27+
let userService: UserService;
28+
let userDataService: UserDataService;
29+
let depositService: DepositService;
30+
let assetService: AssetService;
31+
let payInService: PayInService;
32+
let buyCryptoService: BuyCryptoService;
33+
let buyCryptoWebhookService: BuyCryptoWebhookService;
34+
let transactionUtilService: TransactionUtilService;
35+
let routeService: RouteService;
36+
let transactionHelper: TransactionHelper;
37+
let cryptoService: CryptoService;
38+
let transactionRequestService: TransactionRequestService;
39+
let blockchainRegistryService: BlockchainRegistryService;
40+
let pimlicoPaymasterService: PimlicoPaymasterService;
41+
let pimlicoBundlerService: PimlicoBundlerService;
42+
43+
beforeEach(async () => {
44+
swapRepo = createMock<SwapRepository>();
45+
userService = createMock<UserService>();
46+
userDataService = createMock<UserDataService>();
47+
depositService = createMock<DepositService>();
48+
assetService = createMock<AssetService>();
49+
payInService = createMock<PayInService>();
50+
buyCryptoService = createMock<BuyCryptoService>();
51+
buyCryptoWebhookService = createMock<BuyCryptoWebhookService>();
52+
transactionUtilService = createMock<TransactionUtilService>();
53+
routeService = createMock<RouteService>();
54+
transactionHelper = createMock<TransactionHelper>();
55+
cryptoService = createMock<CryptoService>();
56+
transactionRequestService = createMock<TransactionRequestService>();
57+
blockchainRegistryService = createMock<BlockchainRegistryService>();
58+
pimlicoPaymasterService = createMock<PimlicoPaymasterService>();
59+
pimlicoBundlerService = createMock<PimlicoBundlerService>();
60+
61+
const module: TestingModule = await Test.createTestingModule({
62+
imports: [TestSharedModule],
63+
providers: [
64+
SwapService,
65+
{ provide: SwapRepository, useValue: swapRepo },
66+
{ provide: UserService, useValue: userService },
67+
{ provide: UserDataService, useValue: userDataService },
68+
{ provide: DepositService, useValue: depositService },
69+
{ provide: AssetService, useValue: assetService },
70+
{ provide: PayInService, useValue: payInService },
71+
{ provide: BuyCryptoService, useValue: buyCryptoService },
72+
{ provide: BuyCryptoWebhookService, useValue: buyCryptoWebhookService },
73+
{ provide: TransactionUtilService, useValue: transactionUtilService },
74+
{ provide: RouteService, useValue: routeService },
75+
{ provide: TransactionHelper, useValue: transactionHelper },
76+
{ provide: CryptoService, useValue: cryptoService },
77+
{ provide: TransactionRequestService, useValue: transactionRequestService },
78+
{ provide: BlockchainRegistryService, useValue: blockchainRegistryService },
79+
{ provide: PimlicoPaymasterService, useValue: pimlicoPaymasterService },
80+
{ provide: PimlicoBundlerService, useValue: pimlicoBundlerService },
81+
TestUtil.provideConfig(),
82+
],
83+
}).compile();
84+
85+
service = module.get<SwapService>(SwapService);
86+
});
87+
88+
it('should be defined', () => {
89+
expect(service).toBeDefined();
90+
});
91+
92+
describe('createDepositTx', () => {
93+
const mockRequest = {
94+
id: 1,
95+
sourceId: 100,
96+
amount: 10,
97+
user: { address: '0x1234567890123456789012345678901234567890' },
98+
};
99+
100+
const mockRoute = {
101+
id: 1,
102+
deposit: { address: '0x0987654321098765432109876543210987654321' },
103+
};
104+
105+
const mockAsset = {
106+
id: 100,
107+
blockchain: 'Ethereum',
108+
};
109+
110+
const mockUnsignedTx = {
111+
to: '0x0987654321098765432109876543210987654321',
112+
data: '0xabcdef',
113+
value: '0',
114+
chainId: 1,
115+
};
116+
117+
beforeEach(() => {
118+
jest.spyOn(assetService, 'getAssetById').mockResolvedValue(mockAsset as any);
119+
jest.spyOn(blockchainRegistryService, 'getEvmClient').mockReturnValue({
120+
prepareTransaction: jest.fn().mockResolvedValue({ ...mockUnsignedTx }),
121+
chainId: 1,
122+
} as any);
123+
});
124+
125+
it('should NOT include eip5792 when includeEip5792 is false (default)', async () => {
126+
jest.spyOn(pimlicoPaymasterService, 'isPaymasterAvailable').mockReturnValue(true);
127+
jest.spyOn(pimlicoPaymasterService, 'getBundlerUrl').mockReturnValue('https://api.pimlico.io/test');
128+
129+
const result = await service.createDepositTx(mockRequest as any, mockRoute as any);
130+
131+
expect(result).toBeDefined();
132+
expect(result.eip5792).toBeUndefined();
133+
});
134+
135+
it('should NOT include eip5792 when includeEip5792 is explicitly false', async () => {
136+
jest.spyOn(pimlicoPaymasterService, 'isPaymasterAvailable').mockReturnValue(true);
137+
jest.spyOn(pimlicoPaymasterService, 'getBundlerUrl').mockReturnValue('https://api.pimlico.io/test');
138+
139+
const result = await service.createDepositTx(mockRequest as any, mockRoute as any, false);
140+
141+
expect(result).toBeDefined();
142+
expect(result.eip5792).toBeUndefined();
143+
});
144+
145+
it('should include eip5792 when includeEip5792 is true and paymaster available', async () => {
146+
jest.spyOn(pimlicoPaymasterService, 'isPaymasterAvailable').mockReturnValue(true);
147+
jest.spyOn(pimlicoPaymasterService, 'getBundlerUrl').mockReturnValue('https://api.pimlico.io/test');
148+
149+
const result = await service.createDepositTx(mockRequest as any, mockRoute as any, true);
150+
151+
expect(result).toBeDefined();
152+
expect(result.eip5792).toBeDefined();
153+
expect(result.eip5792.paymasterUrl).toBe('https://api.pimlico.io/test');
154+
expect(result.eip5792.chainId).toBe(1);
155+
expect(result.eip5792.calls).toHaveLength(1);
156+
});
157+
158+
it('should NOT include eip5792 when includeEip5792 is true but paymaster not available', async () => {
159+
jest.spyOn(pimlicoPaymasterService, 'isPaymasterAvailable').mockReturnValue(false);
160+
jest.spyOn(pimlicoPaymasterService, 'getBundlerUrl').mockReturnValue(undefined);
161+
162+
const result = await service.createDepositTx(mockRequest as any, mockRoute as any, true);
163+
164+
expect(result).toBeDefined();
165+
expect(result.eip5792).toBeUndefined();
166+
});
167+
});
168+
});

src/subdomains/core/buy-crypto/routes/swap/dto/swap-payment-info.dto.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
22
import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum';
33
import { AssetDto } from 'src/shared/models/asset/dto/asset.dto';
4+
import { Eip7702AuthorizationDataDto } from 'src/subdomains/core/sell-crypto/route/dto/gasless-transfer.dto';
45
import { UnsignedTxDto } from 'src/subdomains/core/sell-crypto/route/dto/unsigned-tx.dto';
56
import { FeeDto } from 'src/subdomains/supporting/payment/dto/fee.dto';
67
import { MinAmount } from 'src/subdomains/supporting/payment/dto/transaction-helper/min-amount.dto';
@@ -91,4 +92,15 @@ export class SwapPaymentInfoDto {
9192

9293
@ApiPropertyOptional({ enum: QuoteError, description: 'Error message in case isValid is false' })
9394
error?: QuoteError;
95+
96+
@ApiPropertyOptional({
97+
type: Eip7702AuthorizationDataDto,
98+
description: 'EIP-7702 authorization data for gasless transactions (user has 0 native balance)',
99+
})
100+
eip7702Authorization?: Eip7702AuthorizationDataDto;
101+
102+
@ApiPropertyOptional({
103+
description: 'Whether gasless transaction is available for this request',
104+
})
105+
gaslessAvailable?: boolean;
94106
}

src/subdomains/core/buy-crypto/routes/swap/swap.service.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { CronExpression } from '@nestjs/schedule';
1010
import { Config } from 'src/config/config';
1111
import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum';
12+
import { PimlicoBundlerService } from 'src/integration/blockchain/shared/evm/paymaster/pimlico-bundler.service';
1213
import { PimlicoPaymasterService } from 'src/integration/blockchain/shared/evm/paymaster/pimlico-paymaster.service';
1314
import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service';
1415
import { CryptoService } from 'src/integration/blockchain/shared/services/crypto.service';
@@ -69,6 +70,7 @@ export class SwapService {
6970
private readonly transactionRequestService: TransactionRequestService,
7071
private readonly blockchainRegistryService: BlockchainRegistryService,
7172
private readonly pimlicoPaymasterService: PimlicoPaymasterService,
73+
private readonly pimlicoBundlerService: PimlicoBundlerService,
7274
) {}
7375

7476
async getSwapByAddress(depositAddress: string): Promise<Swap> {
@@ -250,7 +252,25 @@ export class SwapService {
250252
let payIn;
251253

252254
try {
253-
if (dto.permit) {
255+
if (dto.authorization) {
256+
type = 'gasless transfer';
257+
const asset = await this.assetService.getAssetById(request.sourceId);
258+
if (!asset) throw new BadRequestException('Asset not found');
259+
260+
if (!this.pimlicoBundlerService.isGaslessSupported(asset.blockchain)) {
261+
throw new BadRequestException(`Gasless transactions not supported for ${asset.blockchain}`);
262+
}
263+
264+
const result = await this.pimlicoBundlerService.executeGaslessTransfer(
265+
request.user.address,
266+
asset,
267+
route.deposit.address,
268+
request.amount,
269+
dto.authorization,
270+
);
271+
272+
payIn = await this.transactionUtilService.handleTxHashInput(route, request, result.txHash);
273+
} else if (dto.permit) {
254274
type = 'permit';
255275
payIn = await this.transactionUtilService.handlePermitInput(route, request, dto.permit);
256276
} else if (dto.signedTxHex) {
@@ -260,7 +280,7 @@ export class SwapService {
260280
type = 'EIP-5792 sponsored transfer';
261281
payIn = await this.transactionUtilService.handleTxHashInput(route, request, dto.txHash);
262282
} else {
263-
throw new BadRequestException('Either permit, signedTxHex, or txHash must be provided');
283+
throw new BadRequestException('Either permit, signedTxHex, txHash, or authorization must be provided');
264284
}
265285

266286
const buyCrypto = await this.buyCryptoService.createFromCryptoInput(payIn, route, request);
@@ -392,8 +412,36 @@ export class SwapService {
392412
// Assign complete user object to ensure user.address is available for createDepositTx
393413
transactionRequest.user = user;
394414

415+
// Check if user needs gasless transaction (0 native balance) - must be done BEFORE createDepositTx
416+
let hasZeroBalance = false;
417+
if (isValid && this.pimlicoBundlerService.isGaslessSupported(dto.sourceAsset.blockchain)) {
418+
try {
419+
hasZeroBalance = await this.pimlicoBundlerService.hasZeroNativeBalance(
420+
user.address,
421+
dto.sourceAsset.blockchain,
422+
);
423+
swapDto.gaslessAvailable = hasZeroBalance;
424+
425+
if (hasZeroBalance) {
426+
swapDto.eip7702Authorization = await this.pimlicoBundlerService.prepareAuthorizationData(
427+
user.address,
428+
dto.sourceAsset.blockchain,
429+
);
430+
}
431+
} catch (e) {
432+
this.logger.warn(`Could not prepare gasless data for swap request ${swap.id}:`, e);
433+
swapDto.gaslessAvailable = false;
434+
}
435+
}
436+
437+
// Create deposit transaction - only include EIP-5792 data if user has 0 native balance
395438
if (includeTx && isValid) {
396-
swapDto.depositTx = await this.createDepositTx(transactionRequest, swap);
439+
try {
440+
swapDto.depositTx = await this.createDepositTx(transactionRequest, swap, hasZeroBalance);
441+
} catch (e) {
442+
this.logger.warn(`Could not create deposit transaction for swap request ${swap.id}, continuing without it:`, e);
443+
swapDto.depositTx = undefined;
444+
}
397445
}
398446

399447
return swapDto;

0 commit comments

Comments
 (0)