Skip to content

Commit ceb4a65

Browse files
committed
chore: refactoring ProxyProtocol and SparkProtocol; updated tests
1 parent a74c99b commit ceb4a65

File tree

8 files changed

+299
-251
lines changed

8 files changed

+299
-251
lines changed

.changeset/late-clowns-add.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@
44

55
## What was changed?
66

7-
1. Added paymaster to ProxyProtocol to use with DeFi opportunities with Mycelium Cloud
8-
2. Updated tests for ProxyProtocol
9-
3. Added Paymaster support for DefaultSmartWallet
10-
4. Updated tests for DefaultSmartWallet.ts
11-
5. Added paymaster to SparkProtocol to use with Spark vaults
12-
6. Updated SparkProtocol tests
7+
- Added paymaster to ProxyProtocol to use with DeFi opportunities with Mycelium Cloud
8+
- Updated tests for ProxyProtocol
9+
- Added Paymaster support for DefaultSmartWallet
10+
- Updated tests for DefaultSmartWallet.ts
11+
- Added paymaster to SparkProtocol to use with Spark vaults
12+
- Updated SparkProtocol tests
1313

1414
## Why was changed/added?
1515

16-
1. Support usage of paymaster with DeFi opportunities from Mycelium Cloud
17-
2. Supported usage of paymaster with DeFi opportunities with core SDK without Mycelium Cloud
16+
- Support usage of paymaster with DeFi opportunities from Mycelium Cloud
17+
- Supported usage of paymaster with DeFi opportunities with core SDK without Mycelium Cloud
1818

1919
## How to use the change?
2020

packages/sdk/src/constants/paymaster.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
export const ERC20_PAYMASTER_ADDRESS = '0x6666666666667849c56f2850848ce1c4da65c68b';
44

55
// Percentage of balance to reserve for gas payment for tokens with high decimals (>8)
6-
export const GAS_RESERVE_PERCENTAGE = 1;
6+
export const GAS_RESERVE_PERCENTAGE = '1';
77

88
// Minimum gas reserve amount in token units for tokens with low decimals (<8)
9-
export const GAS_RESERVE_MINIMUM = 0.01;
9+
export const GAS_RESERVE_MINIMUM = '0.01';

packages/sdk/src/protocols/base/BaseProtocol.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import {
66
createWalletClient,
77
http,
88
parseGwei,
9+
parseUnits,
10+
formatUnits,
911
type PublicClient,
1012
} from 'viem';
13+
import { GAS_RESERVE_MINIMUM, GAS_RESERVE_PERCENTAGE } from '@/constants/paymaster';
1114
import type { SupportedChainId } from '@/constants/chains';
1215
import type { SmartWallet } from '@/wallet/base/wallets/SmartWallet';
1316
import type {
@@ -94,7 +97,7 @@ export abstract class BaseProtocol {
9497
*/
9598
abstract withdraw(
9699
vaultInfo: VaultInfo,
97-
amountInShares: string,
100+
amount: string,
98101
smartWallet: SmartWallet,
99102
options?: { paymasterToken?: Address },
100103
): Promise<VaultTxnResult>;
@@ -169,4 +172,94 @@ export abstract class BaseProtocol {
169172
args: [walletAddress, spenderAddress],
170173
});
171174
}
175+
176+
/**
177+
* Validate gas reserve balance for operations using paymaster
178+
* Ensures sufficient balance remains for gas payment when using paymaster with the same token
179+
* @param tokenAddress Token address to check balance for
180+
* @param walletAddress Wallet address to check
181+
* @param tokenDecimals Number of decimals for the token
182+
* @param operationAmount Optional: Amount for deposit operation (in token units). If provided, validates deposit; otherwise validates withdraw
183+
* @throws Error if balance is insufficient for gas payment or operation
184+
*/
185+
protected async validateGasReserve(
186+
tokenAddress: Address,
187+
walletAddress: Address,
188+
tokenDecimals: number,
189+
operationAmount?: bigint,
190+
): Promise<void> {
191+
this.ensureInitialized();
192+
const publicClient = this.chainManager!.getPublicClient(this.selectedChainId!);
193+
const balance = await publicClient.readContract({
194+
address: tokenAddress,
195+
abi: erc20Abi,
196+
functionName: 'balanceOf',
197+
args: [walletAddress],
198+
});
199+
200+
const gasReserve = this.calculateGasReserve(balance, tokenDecimals);
201+
202+
if (operationAmount !== undefined) {
203+
// Check if operation amount exceeds available balance after gas reserve
204+
const maxDepositAmount = balance > gasReserve ? balance - gasReserve : 0n;
205+
206+
if (operationAmount > maxDepositAmount) {
207+
const maxDepositFormatted = formatUnits(maxDepositAmount, tokenDecimals);
208+
throw new Error(
209+
`Insufficient balance. Must reserve tokens for gas payment. Max deposit: ${maxDepositFormatted}`,
210+
);
211+
}
212+
} else {
213+
// Check if balance meets minimum gas reserve requirement
214+
const minRequiredBalance = gasReserve;
215+
216+
if (balance < minRequiredBalance) {
217+
const minRequiredFormatted = formatUnits(minRequiredBalance, tokenDecimals);
218+
throw new Error(
219+
`Insufficient wallet balance for gas payment. Wallet needs at least ${minRequiredFormatted} tokens to pay for gas before withdrawal.`,
220+
);
221+
}
222+
}
223+
}
224+
225+
/**
226+
* Calculate gas reserve amount based on balance and token decimals
227+
* Uses a more sophisticated calculation that considers token decimal places:
228+
* - For tokens with low decimals (≤6): Uses a fixed minimum amount configured via
229+
* GAS_RESERVE_MINIMUM (e.g., 0.01 tokens), with at least 1 unit reserved
230+
* - For tokens with higher decimals (>6): Uses GAS_RESERVE_PERCENTAGE% of balance
231+
* with a minimum of 1 unit
232+
* @param balance Current token balance
233+
* @param tokenDecimals Number of decimals for the token
234+
* @returns Gas reserve amount in token units
235+
*/
236+
protected calculateGasReserve(balance: bigint, tokenDecimals: number): bigint {
237+
// For tokens with low decimals (e.g., WBTC with 8 decimals), use a fixed minimum
238+
// This ensures sufficient gas coverage for high-value tokens
239+
if (tokenDecimals <= 6) {
240+
// Reserve 0.001 tokens (or 1 unit if that's larger)
241+
const fixedReserve = parseUnits(GAS_RESERVE_MINIMUM, tokenDecimals);
242+
const oneUnit = 1n;
243+
return fixedReserve > oneUnit ? fixedReserve : oneUnit;
244+
}
245+
246+
// For tokens with high decimals, use percentage-based approach
247+
// Reserve 1% of balance with a minimum of 1 unit
248+
const percentageReserve = (balance * BigInt(GAS_RESERVE_PERCENTAGE)) / 100n;
249+
const oneUnit = 1n;
250+
return percentageReserve > 0n ? percentageReserve : oneUnit;
251+
}
252+
253+
/**
254+
* Get the selected chain ID, ensuring it has been initialized
255+
* @returns The selected chain ID
256+
* @throws Error if `init()` has not been called or chain ID is not set
257+
*/
258+
protected getSelectedChainId(): SupportedChainId {
259+
this.ensureInitialized();
260+
if (this.selectedChainId === undefined) {
261+
throw new Error('Protocol chain ID not set. Ensure init() was called successfully.');
262+
}
263+
return this.selectedChainId;
264+
}
172265
}

packages/sdk/src/protocols/implementations/ProxyProtocol.spec.ts

Lines changed: 137 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,8 @@ import type {
1010
VaultInfo,
1111
VaultBalance,
1212
} from '@mycelium-sdk/core/types/protocols/general';
13-
import type {
14-
ProxyVaults,
15-
ProxyBalance,
16-
OperationCallDataType,
17-
} from '@mycelium-sdk/core/types/protocols/proxy';
13+
import type { ProxyVaults, ProxyBalance } from '@mycelium-sdk/core/types/protocols/proxy';
14+
import type { TransactionData } from '@mycelium-sdk/core/types/transaction';
1815
import { encodeFunctionData, erc20Abi, parseUnits, type Address, type Hash } from 'viem';
1916

2017
// Mock viem functions
@@ -178,7 +175,7 @@ describe('ProxyProtocol integration tests', () => {
178175
BigInt('2000000000'), // Allowance is greater than deposit amount
179176
);
180177

181-
const mockOperationData: OperationCallDataType = {
178+
const mockOperationData: TransactionData = {
182179
to: mockVaultInfo.vaultAddress,
183180
data: '0x1234' as `0x${string}`,
184181
};
@@ -220,7 +217,7 @@ describe('ProxyProtocol integration tests', () => {
220217
const mockApproveData = '0xhash123' as `0x${string}`;
221218
vi.mocked(encodeFunctionData).mockReturnValue(mockApproveData);
222219

223-
const mockOperationData: OperationCallDataType = {
220+
const mockOperationData: TransactionData = {
224221
to: mockVaultInfo.vaultAddress,
225222
data: '0x1234' as `0x${string}`,
226223
};
@@ -289,7 +286,7 @@ describe('ProxyProtocol integration tests', () => {
289286
BigInt('2000000000'),
290287
);
291288

292-
const mockOperationData: OperationCallDataType = {
289+
const mockOperationData: TransactionData = {
293290
to: mockVaultInfo.vaultAddress,
294291
data: '0x1234' as `0x${string}`,
295292
};
@@ -318,6 +315,63 @@ describe('ProxyProtocol integration tests', () => {
318315
}),
319316
);
320317
});
318+
319+
it('should deposit with paymaster token when paymaster token equals deposit token', async () => {
320+
const paymasterToken = mockVaultInfo.tokenAddress;
321+
const mockPublicClient = chainManager.getPublicClient(8453);
322+
323+
// Mock balance check (for gas reserve) and allowance check
324+
vi.mocked(mockPublicClient.readContract as ReturnType<typeof vi.fn>)
325+
.mockResolvedValueOnce(BigInt('2000000000'))
326+
.mockResolvedValueOnce(BigInt('2000000000'));
327+
328+
const mockOperationData: TransactionData = {
329+
to: mockVaultInfo.vaultAddress,
330+
data: '0x1234' as `0x${string}`,
331+
};
332+
333+
(apiClient.sendRequest as ReturnType<typeof vi.fn>)
334+
.mockResolvedValueOnce({
335+
success: true,
336+
data: mockOperationData,
337+
})
338+
.mockResolvedValueOnce({
339+
success: true,
340+
});
341+
342+
(smartWallet.sendBatch as ReturnType<typeof vi.fn>).mockResolvedValue('0xhash123' as Hash);
343+
344+
const result = await proxyProtocol.deposit(mockVaultInfo, '1000', smartWallet, {
345+
paymasterToken,
346+
});
347+
348+
expect(mockPublicClient.readContract).toHaveBeenCalledWith({
349+
address: mockVaultInfo.tokenAddress,
350+
abi: erc20Abi,
351+
functionName: 'balanceOf',
352+
args: [expect.any(String)],
353+
});
354+
355+
expect(smartWallet.sendBatch).toHaveBeenCalledWith([mockOperationData], 8453, {
356+
paymasterToken,
357+
});
358+
expect(result.success).toBe(true);
359+
expect(result.hash).toBe('0xhash123');
360+
});
361+
362+
it('should throw error when deposit amount exceeds balance minus gas reserve', async () => {
363+
const paymasterToken = mockVaultInfo.tokenAddress;
364+
const mockPublicClient = chainManager.getPublicClient(8453);
365+
366+
// Mock balance that's too low for gas reserve
367+
vi.mocked(mockPublicClient.readContract as ReturnType<typeof vi.fn>).mockResolvedValue(
368+
BigInt('500000000'),
369+
);
370+
371+
await expect(
372+
proxyProtocol.deposit(mockVaultInfo, '1000', smartWallet, { paymasterToken }),
373+
).rejects.toThrow('Insufficient balance. Must reserve tokens for gas payment.');
374+
});
321375
});
322376

323377
describe('withdraw', () => {
@@ -335,7 +389,7 @@ describe('ProxyProtocol integration tests', () => {
335389

336390
vi.mocked(smartWallet.getEarnBalances).mockResolvedValue(mockEarningBalances);
337391

338-
const mockOperationData: OperationCallDataType = {
392+
const mockOperationData: TransactionData = {
339393
to: mockVaultInfo.vaultAddress,
340394
data: '0xhash123' as `0x${string}`,
341395
};
@@ -374,7 +428,7 @@ describe('ProxyProtocol integration tests', () => {
374428

375429
vi.mocked(smartWallet.getEarnBalances).mockResolvedValue(mockEarningBalances);
376430

377-
const mockOperationData: OperationCallDataType = {
431+
const mockOperationData: TransactionData = {
378432
to: mockVaultInfo.vaultAddress,
379433
data: '0xhash123' as `0x${string}`,
380434
};
@@ -454,7 +508,7 @@ describe('ProxyProtocol integration tests', () => {
454508

455509
vi.mocked(smartWallet.getEarnBalances).mockResolvedValue(mockEarningBalances);
456510

457-
const mockOperationData: OperationCallDataType = {
511+
const mockOperationData: TransactionData = {
458512
to: mockVaultInfo.vaultAddress,
459513
data: '0xhash123' as `0x${string}`,
460514
};
@@ -483,6 +537,78 @@ describe('ProxyProtocol integration tests', () => {
483537
}),
484538
);
485539
});
540+
541+
it('should withdraw with paymaster token when paymaster token equals withdraw token', async () => {
542+
const paymasterToken = mockVaultInfo.tokenAddress;
543+
const mockPublicClient = chainManager.getPublicClient(8453);
544+
const mockEarningBalances: VaultBalance[] = [
545+
{
546+
vaultInfo: mockVaultInfo,
547+
balance: mockProxyBalance,
548+
},
549+
];
550+
551+
vi.mocked(smartWallet.getEarnBalances).mockResolvedValue(mockEarningBalances);
552+
553+
// Mock balance check for gas reserve
554+
vi.mocked(mockPublicClient.readContract as ReturnType<typeof vi.fn>).mockResolvedValue(
555+
BigInt('2000000000'),
556+
);
557+
558+
const mockOperationData: TransactionData = {
559+
to: mockVaultInfo.vaultAddress,
560+
data: '0xhash123' as `0x${string}`,
561+
};
562+
563+
(apiClient.sendRequest as ReturnType<typeof vi.fn>)
564+
.mockResolvedValueOnce({
565+
success: true,
566+
data: mockOperationData,
567+
})
568+
.mockResolvedValueOnce({
569+
success: true,
570+
});
571+
572+
(smartWallet.send as ReturnType<typeof vi.fn>).mockResolvedValue('0xhash456' as Hash);
573+
574+
const result = await proxyProtocol.withdraw(mockVaultInfo, '500', smartWallet, {
575+
paymasterToken,
576+
});
577+
578+
// Verify balance check was performed
579+
expect(mockPublicClient.readContract).toHaveBeenCalledWith({
580+
address: mockVaultInfo.tokenAddress,
581+
abi: erc20Abi,
582+
functionName: 'balanceOf',
583+
args: [expect.any(String)],
584+
});
585+
586+
expect(smartWallet.send).toHaveBeenCalledWith(mockOperationData, 8453, { paymasterToken });
587+
expect(result.success).toBe(true);
588+
expect(result.hash).toBe('0xhash456');
589+
});
590+
591+
it('should throw error when wallet balance is insufficient for gas payment', async () => {
592+
const paymasterToken = mockVaultInfo.tokenAddress;
593+
const mockPublicClient = chainManager.getPublicClient(8453);
594+
const mockEarningBalances: VaultBalance[] = [
595+
{
596+
vaultInfo: mockVaultInfo,
597+
balance: mockProxyBalance,
598+
},
599+
];
600+
601+
vi.mocked(smartWallet.getEarnBalances).mockResolvedValue(mockEarningBalances);
602+
603+
// Mock balance that's too low for gas reserve
604+
vi.mocked(mockPublicClient.readContract as ReturnType<typeof vi.fn>).mockResolvedValue(
605+
BigInt('0'),
606+
);
607+
608+
await expect(
609+
proxyProtocol.withdraw(mockVaultInfo, '500', smartWallet, { paymasterToken }),
610+
).rejects.toThrow('Insufficient wallet balance for gas payment');
611+
});
486612
});
487613

488614
describe('getBalances', () => {

0 commit comments

Comments
 (0)