Skip to content

Commit c17a0d8

Browse files
authored
chore: adds non-evm staking (#7448)
## Explanation Adds a `tron_staking` state structure to support staking APY data for Tron. Tron staking uses a different approach than EVM pooled staking, so rather than forcing Tron into the existing `pooled_staking` structure (which would require nullable vault-specific fields and conditional logic), this introduces a dedicated Tron specific state. We opted for a chain-specific key (`tron_staking`) rather than a generic `non_evm_staking` map because different non-EVM chains may have significantly different data requirements. This approach is more explicit and if we add other non-EVM chains later, they can have their own state structures. The idea next is for the mobile client to: 1 - Call `EarnController.refreshNonEvmStakingApy()` with Tron witness API fetcher 2 - Use the selectors in `selectEarnTokens` to populate `earnToken.experience.apr` with the Tron APY 3- Get rid of the `useTronStakeApy` hook calls from components ## References <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds TRON staking APY support to EarnController with a new `tron_staking` state, fetch/update method, selectors, exports, and tests. > > - **Earn Controller**: > - **State**: Introduces `tron_staking` (`TronStakingState`) with metadata; defaults to `null` (`DEFAULT_TRON_STAKING_STATE`) and included in `getDefaultEarnControllerState`. > - **Methods**: Adds `refreshTronStakingApy(apyFetcher)` and `getTronStakingApy()`. > - **Exports**: Exposes `TronStakingState`, `DEFAULT_TRON_STAKING_STATE`, and new selectors via `src/index.ts`. > - **Selectors**: > - Adds `selectTronStaking` and `selectTronStakingApy`. > - Tightens return types on existing lending selectors. > - **Tests**: > - Adds tests for TRON staking state/methods/selectors and updates metadata snapshots. > - **Docs**: > - Updates `CHANGELOG.md` to note TRON staking APY support. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit be891ac. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent c04f32d commit c17a0d8

File tree

7 files changed

+226
-15
lines changed

7 files changed

+226
-15
lines changed

eslint-suppressions.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -945,9 +945,6 @@
945945
"@typescript-eslint/explicit-function-return-type": {
946946
"count": 9
947947
},
948-
"@typescript-eslint/naming-convention": {
949-
"count": 1
950-
},
951948
"id-length": {
952949
"count": 2
953950
},
@@ -957,7 +954,7 @@
957954
},
958955
"packages/earn-controller/src/selectors.ts": {
959956
"@typescript-eslint/explicit-function-return-type": {
960-
"count": 6
957+
"count": 3
961958
}
962959
},
963960
"packages/eip-5792-middleware/src/constants.ts": {

packages/earn-controller/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add Tron staking APY support with `tron_staking` state, methods, and selectors ([#7448](https://github.com/MetaMask/core/pull/7448))
13+
1014
### Changed
1115

1216
- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7258](https://github.com/MetaMask/core/pull/7258), [#7534](https://github.com/MetaMask/core/pull/7534))

packages/earn-controller/src/EarnController.test.ts

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,14 @@ describe('EarnController', () => {
807807

808808
// Verify that default lending state is still present
809809
expect(controller.state.lending).toBeDefined();
810+
811+
// Verify that default tron_staking state is still present
812+
expect(controller.state.tron_staking).toBeNull();
813+
});
814+
815+
it('initializes with null tron_staking state by default', async () => {
816+
const { controller } = await setupController();
817+
expect(controller.state.tron_staking).toBeNull();
810818
});
811819

812820
it('initializes API service with default environment (PROD)', async () => {
@@ -2585,6 +2593,79 @@ describe('EarnController', () => {
25852593
});
25862594
});
25872595

2596+
describe('TRON Staking', () => {
2597+
describe('refreshTronStakingApy', () => {
2598+
it('updates state with fetched APY data', async () => {
2599+
const { controller } = await setupController();
2600+
const mockApy = '3.35';
2601+
const mockApyFetcher = jest.fn().mockResolvedValue(mockApy);
2602+
2603+
await controller.refreshTronStakingApy(mockApyFetcher);
2604+
2605+
expect(mockApyFetcher).toHaveBeenCalledTimes(1);
2606+
expect(controller.state.tron_staking).toStrictEqual(
2607+
expect.objectContaining({
2608+
apy: '3.35',
2609+
lastUpdated: expect.any(Number),
2610+
}),
2611+
);
2612+
});
2613+
2614+
it('overwrites existing APY data', async () => {
2615+
const { controller } = await setupController();
2616+
2617+
await controller.refreshTronStakingApy(
2618+
jest.fn().mockResolvedValue('3.35'),
2619+
);
2620+
2621+
const firstLastUpdated = controller.state.tron_staking?.lastUpdated;
2622+
2623+
await new Promise((resolve) => setTimeout(resolve, 10));
2624+
2625+
await controller.refreshTronStakingApy(
2626+
jest.fn().mockResolvedValue('4.0'),
2627+
);
2628+
2629+
expect(controller.state.tron_staking?.apy).toBe('4.0');
2630+
expect(controller.state.tron_staking?.lastUpdated).toBeGreaterThan(
2631+
firstLastUpdated as number,
2632+
);
2633+
});
2634+
2635+
it('handles apyFetcher errors', async () => {
2636+
const { controller } = await setupController();
2637+
const mockError = new Error('Failed to fetch APY');
2638+
const mockApyFetcher = jest.fn().mockRejectedValue(mockError);
2639+
2640+
await expect(
2641+
controller.refreshTronStakingApy(mockApyFetcher),
2642+
).rejects.toThrow('Failed to fetch APY');
2643+
2644+
expect(controller.state.tron_staking).toBeNull();
2645+
});
2646+
});
2647+
2648+
describe('getTronStakingApy', () => {
2649+
it('returns APY when available', async () => {
2650+
const { controller } = await setupController();
2651+
2652+
await controller.refreshTronStakingApy(
2653+
jest.fn().mockResolvedValue('3.35'),
2654+
);
2655+
2656+
const result = controller.getTronStakingApy();
2657+
expect(result).toBe('3.35');
2658+
});
2659+
2660+
it('returns undefined when not available', async () => {
2661+
const { controller } = await setupController();
2662+
2663+
const result = controller.getTronStakingApy();
2664+
expect(result).toBeUndefined();
2665+
});
2666+
});
2667+
});
2668+
25882669
describe('metadata', () => {
25892670
it('includes expected state in debug snapshots', async () => {
25902671
const { controller } = await setupController();
@@ -2611,9 +2692,10 @@ describe('EarnController', () => {
26112692
'includeInStateLogs',
26122693
);
26132694

2614-
// Compare `pooled_staking` separately to minimize size of snapshot
2695+
// Compare `pooled_staking` and `tron_staking` separately to minimize size of snapshot
26152696
const {
26162697
pooled_staking: derivedPooledStaking,
2698+
tron_staking: derivedTronStaking,
26172699
...derivedStateWithoutPooledStaking
26182700
} = derivedState;
26192701
expect(derivedPooledStaking).toStrictEqual({
@@ -2633,6 +2715,7 @@ describe('EarnController', () => {
26332715
},
26342716
isEligible: true,
26352717
});
2718+
expect(derivedTronStaking).toBeNull();
26362719
expect(derivedStateWithoutPooledStaking).toMatchInlineSnapshot(`
26372720
Object {
26382721
"lastUpdated": 0,
@@ -2702,9 +2785,10 @@ describe('EarnController', () => {
27022785
'persist',
27032786
);
27042787

2705-
// Compare `pooled_staking` separately to minimize size of snapshot
2788+
// Compare `pooled_staking` and `tron_staking` separately to minimize size of snapshot
27062789
const {
27072790
pooled_staking: derivedPooledStaking,
2791+
tron_staking: derivedTronStaking,
27082792
...derivedStateWithoutPooledStaking
27092793
} = derivedState;
27102794
expect(derivedPooledStaking).toStrictEqual({
@@ -2724,6 +2808,7 @@ describe('EarnController', () => {
27242808
},
27252809
isEligible: true,
27262810
});
2811+
expect(derivedTronStaking).toBeNull();
27272812
expect(derivedStateWithoutPooledStaking).toMatchInlineSnapshot(`
27282813
Object {
27292814
"lending": Object {
@@ -2792,9 +2877,10 @@ describe('EarnController', () => {
27922877
'usedInUi',
27932878
);
27942879

2795-
// Compare `pooled_staking` separately to minimize size of snapshot
2880+
// Compare `pooled_staking` and `tron_staking` separately to minimize size of snapshot
27962881
const {
27972882
pooled_staking: derivedPooledStaking,
2883+
tron_staking: derivedTronStaking,
27982884
...derivedStateWithoutPooledStaking
27992885
} = derivedState;
28002886
expect(derivedPooledStaking).toStrictEqual({
@@ -2814,6 +2900,7 @@ describe('EarnController', () => {
28142900
},
28152901
isEligible: true,
28162902
});
2903+
expect(derivedTronStaking).toBeNull();
28172904
expect(derivedStateWithoutPooledStaking).toMatchInlineSnapshot(`
28182905
Object {
28192906
"lending": Object {

packages/earn-controller/src/EarnController.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@ export type LendingState = {
9292
isEligible: boolean;
9393
};
9494

95+
/**
96+
* State for TRON staking.
97+
*/
98+
export type TronStakingState = {
99+
/** The annual percentage yield as a decimal string (e.g., "3.35" for 3.35%) */
100+
apy: string;
101+
/** Timestamp of when the APY was last fetched */
102+
lastUpdated: number;
103+
} | null;
104+
95105
type StakingTransactionTypes =
96106
| TransactionType.stakingDeposit
97107
| TransactionType.stakingUnstake
@@ -128,6 +138,12 @@ const earnControllerMetadata: StateMetadata<EarnControllerState> = {
128138
includeInDebugSnapshot: false,
129139
usedInUi: true,
130140
},
141+
tron_staking: {
142+
includeInStateLogs: true,
143+
persist: true,
144+
includeInDebugSnapshot: false,
145+
usedInUi: true,
146+
},
131147
lastUpdated: {
132148
includeInStateLogs: true,
133149
persist: false,
@@ -138,8 +154,11 @@ const earnControllerMetadata: StateMetadata<EarnControllerState> = {
138154

139155
// === State Types ===
140156
export type EarnControllerState = {
157+
// eslint-disable-next-line @typescript-eslint/naming-convention
141158
pooled_staking: PooledStakingState;
142159
lending: LendingState;
160+
// eslint-disable-next-line @typescript-eslint/naming-convention
161+
tron_staking: TronStakingState;
143162
lastUpdated: number;
144163
};
145164

@@ -209,6 +228,8 @@ export const DEFAULT_POOLED_STAKING_CHAIN_STATE = {
209228
vaultApyAverages: DEFAULT_POOLED_STAKING_VAULT_APY_AVERAGES,
210229
};
211230

231+
export const DEFAULT_TRON_STAKING_STATE: TronStakingState = null;
232+
212233
/**
213234
* Gets the default state for the EarnController.
214235
*
@@ -224,6 +245,7 @@ export function getDefaultEarnControllerState(): EarnControllerState {
224245
positions: [DEFAULT_LENDING_POSITION],
225246
isEligible: false,
226247
},
248+
tron_staking: DEFAULT_TRON_STAKING_STATE,
227249
lastUpdated: 0,
228250
};
229251
}
@@ -806,6 +828,35 @@ export class EarnController extends BaseController<
806828
}
807829
}
808830

831+
/**
832+
* Refreshes the APY for TRON staking.
833+
* The consumer provides a fetcher function that returns the APY for TRON.
834+
*
835+
* @param apyFetcher - An async function that fetches and returns the APY as a decimal string.
836+
* @returns A promise that resolves when the APY has been updated.
837+
*/
838+
async refreshTronStakingApy(
839+
apyFetcher: () => Promise<string>,
840+
): Promise<void> {
841+
const apy = await apyFetcher();
842+
843+
this.update((state) => {
844+
state.tron_staking = {
845+
apy,
846+
lastUpdated: Date.now(),
847+
};
848+
});
849+
}
850+
851+
/**
852+
* Gets the TRON staking APY.
853+
*
854+
* @returns The APY for TRON staking, or undefined if not available.
855+
*/
856+
getTronStakingApy(): string | undefined {
857+
return this.state.tron_staking?.apy;
858+
}
859+
809860
/**
810861
* Gets the lending position history for the current account.
811862
*

packages/earn-controller/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export type {
22
PooledStakingState,
33
LendingState,
4+
TronStakingState,
45
LendingMarketWithPosition,
56
LendingPositionWithMarket,
67
LendingPositionWithMarketReference,
@@ -15,6 +16,7 @@ export type {
1516
export {
1617
controllerName,
1718
getDefaultEarnControllerState,
19+
DEFAULT_TRON_STAKING_STATE,
1820
EarnController,
1921
} from './EarnController';
2022

@@ -36,6 +38,8 @@ export {
3638
selectLendingMarketsByTokenAddress,
3739
selectLendingMarketsByChainIdAndOutputTokenAddress,
3840
selectLendingMarketsByChainIdAndTokenAddress,
41+
selectTronStaking,
42+
selectTronStakingApy,
3943
} from './selectors';
4044

4145
export {

packages/earn-controller/src/selectors.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
selectLendingMarketsByChainIdAndOutputTokenAddress,
2323
selectLendingMarketsByChainIdAndTokenAddress,
2424
selectIsLendingEligible,
25+
selectTronStaking,
26+
selectTronStakingApy,
2527
} from './selectors';
2628

2729
describe('Earn Controller Selectors', () => {
@@ -135,6 +137,10 @@ describe('Earn Controller Selectors', () => {
135137
},
136138
isEligible: false,
137139
},
140+
tron_staking: {
141+
apy: '3.35',
142+
lastUpdated: 1718000000000,
143+
},
138144
lastUpdated: 0,
139145
};
140146

@@ -413,4 +419,41 @@ describe('Earn Controller Selectors', () => {
413419
expect(result).toBe(false);
414420
});
415421
});
422+
423+
describe('TRON Staking Selectors', () => {
424+
describe('selectTronStaking', () => {
425+
it('should return the TRON staking state', () => {
426+
const result = selectTronStaking(mockState);
427+
expect(result).toStrictEqual({
428+
apy: '3.35',
429+
lastUpdated: 1718000000000,
430+
});
431+
});
432+
433+
it('should return null when no TRON staking data exists', () => {
434+
const stateWithoutTronStaking = {
435+
...mockState,
436+
tron_staking: null,
437+
};
438+
const result = selectTronStaking(stateWithoutTronStaking);
439+
expect(result).toBeNull();
440+
});
441+
});
442+
443+
describe('selectTronStakingApy', () => {
444+
it('should return the TRON staking APY', () => {
445+
const result = selectTronStakingApy(mockState);
446+
expect(result).toBe('3.35');
447+
});
448+
449+
it('should return undefined when TRON staking is null', () => {
450+
const stateWithoutTronStaking = {
451+
...mockState,
452+
tron_staking: null,
453+
};
454+
const result = selectTronStakingApy(stateWithoutTronStaking);
455+
expect(result).toBeUndefined();
456+
});
457+
});
458+
});
416459
});

0 commit comments

Comments
 (0)