Skip to content

Commit d098156

Browse files
Add hardware wallet staking tests
1 parent ae12c49 commit d098156

6 files changed

Lines changed: 410 additions & 27 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { EthereumWalletType } from '@/helpers/walletTypes';
2+
import { delegation } from '@rainbow-me/delegation';
3+
4+
import { canUseDelegatedExecution, supportsDelegatedExecution } from './willDelegate';
5+
6+
const mockGetWalletWithAccount = jest.fn();
7+
const mockIsDelegationEnabled = jest.fn();
8+
9+
jest.mock('@/state/wallets/walletsStore', () => ({
10+
getWalletWithAccount: (accountAddress: string) => mockGetWalletWithAccount(accountAddress),
11+
useWalletsStore: jest.fn(),
12+
}));
13+
14+
jest.mock('./featureFlags', () => ({
15+
isDelegationEnabled: () => mockIsDelegationEnabled(),
16+
useIsDelegationEnabled: jest.fn(),
17+
}));
18+
19+
jest.mock('@rainbow-me/delegation', () => ({
20+
delegation: {
21+
isEnabled: jest.fn(),
22+
isSupported: jest.fn(),
23+
willDelegate: jest.fn(),
24+
},
25+
useWillDelegate: jest.fn(),
26+
}));
27+
28+
const ADDRESS = '0x1111111111111111111111111111111111111111';
29+
const CHAIN_ID = 8453;
30+
31+
function setWallet(type: EthereumWalletType) {
32+
mockGetWalletWithAccount.mockReturnValue({
33+
addresses: [],
34+
type,
35+
});
36+
}
37+
38+
describe('delegation wallet gating', () => {
39+
beforeEach(() => {
40+
jest.clearAllMocks();
41+
mockIsDelegationEnabled.mockReturnValue(true);
42+
jest.mocked(delegation.isEnabled).mockReturnValue(true);
43+
jest.mocked(delegation.isSupported).mockResolvedValue({ supported: true, reason: null });
44+
});
45+
46+
it('rejects hardware wallets even when their optional deviceId is missing', async () => {
47+
setWallet(EthereumWalletType.bluetooth);
48+
49+
expect(canUseDelegatedExecution(ADDRESS)).toBe(false);
50+
await expect(supportsDelegatedExecution({ address: ADDRESS, chainId: CHAIN_ID })).resolves.toBe(false);
51+
expect(delegation.isSupported).not.toHaveBeenCalled();
52+
});
53+
54+
it('delegates software wallets through SDK support', async () => {
55+
setWallet(EthereumWalletType.privateKey);
56+
57+
await expect(supportsDelegatedExecution({ address: ADDRESS, chainId: CHAIN_ID })).resolves.toBe(true);
58+
expect(delegation.isSupported).toHaveBeenCalledWith({ address: ADDRESS, chainId: CHAIN_ID });
59+
});
60+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { BigNumber } from '@ethersproject/bignumber';
2+
3+
import { STAKING_APPROVAL_GAS_LIMIT, STAKING_GAS_LIMIT } from '../constants';
4+
import { checkIfStakingNeedsApproval } from './checkIfStakingNeedsApproval';
5+
import { estimateStakeGasLimit } from './estimateStakeGasLimit';
6+
7+
const mockEstimateGas = jest.fn();
8+
9+
jest.mock('@/utils/ethereumUtils', () => ({
10+
getUniqueId: (address: string, chainId: number) => `${chainId}:${address}`,
11+
}));
12+
13+
jest.mock('@/handlers/web3', () => ({
14+
getProvider: () => ({
15+
estimateGas: mockEstimateGas,
16+
}),
17+
}));
18+
19+
jest.mock('./checkIfStakingNeedsApproval', () => ({
20+
checkIfStakingNeedsApproval: jest.fn(),
21+
}));
22+
23+
const ADDRESS = '0xe5ab64c46313d229d33f7dab3490c9c34806ffb3';
24+
25+
describe('estimateStakeGasLimit', () => {
26+
beforeEach(() => {
27+
jest.clearAllMocks();
28+
});
29+
30+
it('uses the shared approval fallback when approval gas estimation fails', async () => {
31+
jest.mocked(checkIfStakingNeedsApproval).mockResolvedValue(true);
32+
mockEstimateGas.mockResolvedValueOnce(BigNumber.from(STAKING_GAS_LIMIT)).mockRejectedValueOnce(new Error('estimateGas failed'));
33+
34+
await expect(estimateStakeGasLimit({ accountAddress: ADDRESS, amount: '100' })).resolves.toBe(
35+
`${STAKING_GAS_LIMIT + STAKING_APPROVAL_GAS_LIMIT}`
36+
);
37+
expect(mockEstimateGas).toHaveBeenCalledTimes(2);
38+
});
39+
});

src/features/rnbw-staking/utils/executeStakeRnbw.test.ts

Lines changed: 102 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { Provider, TransactionReceipt, TransactionRequest, TransactionResponse } from '@ethersproject/abstract-provider';
22
import { Signer } from '@ethersproject/abstract-signer';
33
import { BigNumber } from '@ethersproject/bignumber';
4-
import type { Deferrable } from '@ethersproject/properties';
4+
import { hexlify } from '@ethersproject/bytes';
5+
import { resolveProperties, type Deferrable } from '@ethersproject/properties';
56
import { StaticJsonRpcProvider } from '@ethersproject/providers';
67
import { Wallet } from '@ethersproject/wallet';
78
import { type Address } from 'viem';
@@ -15,9 +16,9 @@ import {
1516
RNBW_DECIMALS,
1617
RNBW_TOKEN_ADDRESS,
1718
RNBW_TOKEN_UNIQUE_ID,
19+
STAKING_APPROVAL_GAS_LIMIT,
1820
STAKING_CHAIN_ID,
1921
STAKING_CONTRACT_ADDRESS,
20-
STAKING_GAS_LIMIT,
2122
} from '../constants';
2223
import { executeStakeRnbw } from './executeStakeRnbw';
2324

@@ -76,18 +77,22 @@ jest.mock('./stakeRnbwCalls', () => ({
7677
buildStakeRnbwExecutionPlan: (params: unknown) => mockBuildStakeRnbwExecutionPlan(params),
7778
}));
7879

79-
const ACCOUNT = '0x3333333333333333333333333333333333333333' satisfies Address;
80+
const ACCOUNT: Address = '0x3333333333333333333333333333333333333333';
8081
const PRIVATE_KEY = '0x0123456789012345678901234567890123456789012345678901234567890123';
8182
const STAKE_AMOUNT_RAW = '1000000000000000000';
8283
const APPROVAL_TX_HASH = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
8384
const TX_HASH = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
8485

86+
const APPROVAL_CALL: Call = { data: '0x095ea7b3', to: RNBW_TOKEN_ADDRESS, value: 0n };
87+
const STAKE_CALL: Call = { data: '0xa694fc3a', to: STAKING_CONTRACT_ADDRESS, value: 0n };
88+
const SPONSORED_REQUIREMENTS: CallsRequirements = { atomic: 'required', fees: { payer: 'sponsor' } };
89+
const SPONSORED_PLAN = { calls: [APPROVAL_CALL, STAKE_CALL], requirements: SPONSORED_REQUIREMENTS };
90+
91+
const GAS_PARAMS = { maxFeePerGas: '6000000', maxPriorityFeePerGas: '1000000' };
92+
const ESTIMATED_STAKE_GAS_LIMIT = BigNumber.from(103_406);
93+
8594
const provider = new StaticJsonRpcProvider('http://127.0.0.1:8545', STAKING_CHAIN_ID);
8695
const signer = new Wallet(PRIVATE_KEY, provider);
87-
const APPROVAL_CALL = { data: '0x095ea7b3', to: RNBW_TOKEN_ADDRESS, value: 0n } satisfies Call;
88-
const STAKE_CALL = { data: '0xa694fc3a', to: STAKING_CONTRACT_ADDRESS, value: 0n } satisfies Call;
89-
const SPONSORED_REQUIREMENTS = { atomic: 'required', fees: { payer: 'sponsor' } } satisfies CallsRequirements;
90-
const SPONSORED_PLAN = { calls: [APPROVAL_CALL, STAKE_CALL], requirements: SPONSORED_REQUIREMENTS };
9196

9297
class TestHardwareSigner extends Signer {
9398
readonly provider: Provider;
@@ -165,24 +170,39 @@ function buildReceipt(transactionHash = TX_HASH): TransactionReceipt {
165170
}
166171

167172
function buildTransactionResponse({
168-
call = STAKE_CALL,
169173
hash = TX_HASH,
174+
nonce = 1,
175+
transaction,
170176
}: {
171-
call?: Call;
172177
hash?: string;
173-
} = {}): TransactionResponse {
174-
return {
178+
nonce?: number;
179+
transaction: Deferrable<TransactionRequest>;
180+
}): Promise<TransactionResponse> {
181+
return resolveProperties(transaction).then(request => ({
175182
chainId: STAKING_CHAIN_ID,
176183
confirmations: 0,
177-
data: call.data,
184+
data: request.data ? hexlify(request.data) : '0x',
178185
from: ACCOUNT,
179-
gasLimit: BigNumber.from(21_000),
186+
gasLimit: BigNumber.from(request.gasLimit ?? 21_000),
187+
gasPrice: BigNumber.from(1),
180188
hash,
181-
nonce: 1,
182-
to: call.to,
183-
value: BigNumber.from(0),
189+
maxFeePerGas: request.maxFeePerGas == null ? undefined : BigNumber.from(request.maxFeePerGas),
190+
maxPriorityFeePerGas: request.maxPriorityFeePerGas == null ? undefined : BigNumber.from(request.maxPriorityFeePerGas),
191+
nonce,
192+
to: request.to,
193+
value: BigNumber.from(request.value ?? 0),
184194
wait: () => Promise.resolve(buildReceipt(hash)),
185-
};
195+
}));
196+
}
197+
198+
function mockSubmittedTransactions(signer: TestHardwareSigner, submissions: { hash: string; nonce: number }[]): void {
199+
let index = 0;
200+
signer.sendTransactionMock.mockImplementation(transaction => {
201+
const submission = submissions[index];
202+
if (!submission) throw new Error(`Unexpected staking transaction ${index + 1}`);
203+
index += 1;
204+
return buildTransactionResponse({ ...submission, transaction });
205+
});
186206
}
187207

188208
async function prepareCalls(plan: unknown): Promise<PreparedCallsExecution> {
@@ -210,8 +230,15 @@ async function waitForMockCalls(mock: { mock: { calls: unknown[][] } }, count: n
210230
if (mock.mock.calls.length < count) throw new Error(`Expected mock to be called ${count} times`);
211231
}
212232

233+
async function submittedRequest(signer: TestHardwareSigner, transactionNumber: number): Promise<TransactionRequest> {
234+
const call = signer.sendTransactionMock.mock.calls[transactionNumber - 1];
235+
if (!call) throw new Error(`Missing staking transaction ${transactionNumber}`);
236+
return resolveProperties(call[0]);
237+
}
238+
213239
describe('executeStakeRnbw', () => {
214240
beforeEach(() => {
241+
jest.restoreAllMocks();
215242
jest.clearAllMocks();
216243
mockBuildStakeRnbwCalls.mockResolvedValue([STAKE_CALL]);
217244
mockBuildStakeRnbwExecutionPlan.mockResolvedValue({ calls: [STAKE_CALL] });
@@ -251,6 +278,7 @@ describe('executeStakeRnbw', () => {
251278
const result = await executeStakeRnbw({
252279
address: ACCOUNT,
253280
asset: rnbwAsset,
281+
gasParams: GAS_PARAMS,
254282
preparedCalls,
255283
provider,
256284
signer,
@@ -292,6 +320,7 @@ describe('executeStakeRnbw', () => {
292320
const result = await executeStakeRnbw({
293321
address: ACCOUNT,
294322
asset: rnbwAsset,
323+
gasParams: GAS_PARAMS,
295324
preparedCalls,
296325
provider,
297326
signer,
@@ -360,6 +389,7 @@ describe('executeStakeRnbw', () => {
360389
const result = await executeStakeRnbw({
361390
address: ACCOUNT,
362391
asset: rnbwAsset,
392+
gasParams: GAS_PARAMS,
363393
preparedCalls: null,
364394
provider,
365395
signer,
@@ -379,22 +409,28 @@ describe('executeStakeRnbw', () => {
379409
);
380410
});
381411

382-
it('waits for hardware-wallet approval before submitting the stake transaction', async () => {
412+
it('uses selected gas params, estimates call gas, and waits for hardware-wallet approval before staking', async () => {
383413
const hardwareSigner = new TestHardwareSigner(provider);
384414
const approvalConfirmation = defer<TransactionReceipt>();
385415
const waitForTransaction = jest.spyOn(provider, 'waitForTransaction').mockImplementation(hash => {
386416
if (hash === APPROVAL_TX_HASH) return approvalConfirmation.promise;
387417
return Promise.resolve(buildReceipt(hash));
388418
});
419+
jest
420+
.spyOn(provider, 'estimateGas')
421+
.mockRejectedValueOnce(new Error('approval estimate failed'))
422+
.mockResolvedValueOnce(ESTIMATED_STAKE_GAS_LIMIT);
389423

390424
mockBuildStakeRnbwCalls.mockResolvedValue([APPROVAL_CALL, STAKE_CALL]);
391-
hardwareSigner.sendTransactionMock
392-
.mockResolvedValueOnce(buildTransactionResponse({ call: APPROVAL_CALL, hash: APPROVAL_TX_HASH }))
393-
.mockResolvedValueOnce(buildTransactionResponse({ call: STAKE_CALL, hash: TX_HASH }));
425+
mockSubmittedTransactions(hardwareSigner, [
426+
{ hash: APPROVAL_TX_HASH, nonce: 1 },
427+
{ hash: TX_HASH, nonce: 2 },
428+
]);
394429

395430
const execution = executeStakeRnbw({
396431
address: ACCOUNT,
397432
asset: rnbwAsset,
433+
gasParams: GAS_PARAMS,
398434
preparedCalls: null,
399435
provider,
400436
signer: hardwareSigner,
@@ -404,7 +440,12 @@ describe('executeStakeRnbw', () => {
404440
await waitForMockCalls(waitForTransaction, 1);
405441

406442
expect(hardwareSigner.sendTransactionMock).toHaveBeenCalledTimes(1);
407-
expect(hardwareSigner.sendTransactionMock).toHaveBeenNthCalledWith(1, APPROVAL_CALL);
443+
await expect(submittedRequest(hardwareSigner, 1)).resolves.toMatchObject({
444+
to: RNBW_TOKEN_ADDRESS,
445+
value: 0n,
446+
gasLimit: STAKING_APPROVAL_GAS_LIMIT,
447+
...GAS_PARAMS,
448+
});
408449
expect(waitForTransaction).toHaveBeenCalledWith(APPROVAL_TX_HASH, 1, time.minutes(2));
409450

410451
approvalConfirmation.resolve(buildReceipt(APPROVAL_TX_HASH));
@@ -417,19 +458,53 @@ describe('executeStakeRnbw', () => {
417458
chainId: STAKING_CHAIN_ID,
418459
transaction: expect.objectContaining({
419460
hash: TX_HASH,
420-
gasLimit: BigNumber.from(21_000),
421-
nonce: 1,
461+
gasLimit: ESTIMATED_STAKE_GAS_LIMIT,
462+
nonce: 2,
422463
type: 'stake',
423464
}),
424465
});
425-
expect(hardwareSigner.sendTransactionMock).toHaveBeenNthCalledWith(2, {
426-
...STAKE_CALL,
427-
gasLimit: STAKING_GAS_LIMIT,
466+
await expect(submittedRequest(hardwareSigner, 2)).resolves.toMatchObject({
467+
to: STAKING_CONTRACT_ADDRESS,
468+
value: 0n,
469+
gasLimit: ESTIMATED_STAKE_GAS_LIMIT,
470+
...GAS_PARAMS,
428471
});
429472

430473
await result.waitForConfirmation();
431474

432475
expect(waitForTransaction).toHaveBeenCalledTimes(2);
433476
expect(waitForTransaction).toHaveBeenLastCalledWith(TX_HASH, 1, time.minutes(2));
434477
});
478+
479+
it('submits one hardware-wallet stake transaction when approval is not required', async () => {
480+
const hardwareSigner = new TestHardwareSigner(provider);
481+
const waitForTransaction = jest.spyOn(provider, 'waitForTransaction').mockResolvedValue(buildReceipt(TX_HASH));
482+
jest.spyOn(provider, 'estimateGas').mockResolvedValue(ESTIMATED_STAKE_GAS_LIMIT);
483+
484+
mockBuildStakeRnbwCalls.mockResolvedValue([STAKE_CALL]);
485+
mockSubmittedTransactions(hardwareSigner, [{ hash: TX_HASH, nonce: 2 }]);
486+
487+
const execution = await executeStakeRnbw({
488+
address: ACCOUNT,
489+
asset: rnbwAsset,
490+
gasParams: GAS_PARAMS,
491+
preparedCalls: null,
492+
provider,
493+
signer: hardwareSigner,
494+
stakeAmountRaw: STAKE_AMOUNT_RAW,
495+
});
496+
497+
expect(hardwareSigner.sendTransactionMock).toHaveBeenCalledTimes(1);
498+
await expect(submittedRequest(hardwareSigner, 1)).resolves.toMatchObject({
499+
to: STAKING_CONTRACT_ADDRESS,
500+
value: 0n,
501+
gasLimit: ESTIMATED_STAKE_GAS_LIMIT,
502+
...GAS_PARAMS,
503+
});
504+
expect(waitForTransaction).not.toHaveBeenCalled();
505+
506+
await execution.waitForConfirmation();
507+
508+
expect(waitForTransaction).toHaveBeenCalledWith(TX_HASH, 1, time.minutes(2));
509+
});
435510
});

0 commit comments

Comments
 (0)