Skip to content
Open
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
130 changes: 127 additions & 3 deletions packages/earn-controller/src/EarnController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,14 @@ describe('EarnController', () => {

// Verify that default lending state is still present
expect(controller.state.lending).toBeDefined();

// Verify that default non_evm_staking state is still present
expect(controller.state.non_evm_staking).toStrictEqual({});
});

it('initializes with empty non_evm_staking state by default', async () => {
const { controller } = await setupController();
expect(controller.state.non_evm_staking).toStrictEqual({});
});

it('initializes API service with default environment (PROD)', async () => {
Expand Down Expand Up @@ -2582,6 +2590,116 @@ describe('EarnController', () => {
});
});

describe('Non-EVM Staking', () => {
describe('refreshNonEvmStakingApy', () => {
it('updates state with fetched APY data', async () => {
const { controller } = await setupController();
const mockApy = '3.35';
const mockApyFetcher = jest.fn().mockResolvedValue(mockApy);

await controller.refreshNonEvmStakingApy({
chainId: 'tron:0x2b6653dc',
apyFetcher: mockApyFetcher,
});

expect(mockApyFetcher).toHaveBeenCalledTimes(1);
expect(
controller.state.non_evm_staking['tron:0x2b6653dc'],
).toStrictEqual(
expect.objectContaining({
apy: '3.35',
lastUpdated: expect.any(Number),
}),
);
});

it('updates state for multiple chains independently', async () => {
const { controller } = await setupController();

await controller.refreshNonEvmStakingApy({
chainId: 'tron:0x2b6653dc',
apyFetcher: jest.fn().mockResolvedValue('3.35'),
});

await controller.refreshNonEvmStakingApy({
chainId: 'solana:mainnet',
apyFetcher: jest.fn().mockResolvedValue('7.5'),
});

expect(controller.state.non_evm_staking['tron:0x2b6653dc'].apy).toBe(
'3.35',
);
expect(controller.state.non_evm_staking['solana:mainnet'].apy).toBe(
'7.5',
);
});

it('overwrites existing APY data for the same chain', async () => {
const { controller } = await setupController();

await controller.refreshNonEvmStakingApy({
chainId: 'tron:0x2b6653dc',
apyFetcher: jest.fn().mockResolvedValue('3.35'),
});

const firstLastUpdated =
controller.state.non_evm_staking['tron:0x2b6653dc'].lastUpdated;

await new Promise((resolve) => setTimeout(resolve, 10));

await controller.refreshNonEvmStakingApy({
chainId: 'tron:0x2b6653dc',
apyFetcher: jest.fn().mockResolvedValue('4.0'),
});

expect(controller.state.non_evm_staking['tron:0x2b6653dc'].apy).toBe(
'4.0',
);
expect(
controller.state.non_evm_staking['tron:0x2b6653dc'].lastUpdated,
).toBeGreaterThan(firstLastUpdated);
});

it('handles apyFetcher errors', async () => {
const { controller } = await setupController();
const mockError = new Error('Failed to fetch APY');
const mockApyFetcher = jest.fn().mockRejectedValue(mockError);

await expect(
controller.refreshNonEvmStakingApy({
chainId: 'tron:0x2b6653dc',
apyFetcher: mockApyFetcher,
}),
).rejects.toThrow('Failed to fetch APY');

expect(
controller.state.non_evm_staking['tron:0x2b6653dc'],
).toBeUndefined();
});
});

describe('getNonEvmStakingApy', () => {
it('returns APY for existing chain', async () => {
const { controller } = await setupController();

await controller.refreshNonEvmStakingApy({
chainId: 'tron:0x2b6653dc',
apyFetcher: jest.fn().mockResolvedValue('3.35'),
});

const result = controller.getNonEvmStakingApy('tron:0x2b6653dc');
expect(result).toBe('3.35');
});

it('returns undefined for non-existent chain', async () => {
const { controller } = await setupController();

const result = controller.getNonEvmStakingApy('unknown:chain');
expect(result).toBeUndefined();
});
});
});

describe('metadata', () => {
it('includes expected state in debug snapshots', async () => {
const { controller } = await setupController();
Expand All @@ -2608,9 +2726,10 @@ describe('EarnController', () => {
'includeInStateLogs',
);

// Compare `pooled_staking` separately to minimize size of snapshot
// Compare `pooled_staking` and `non_evm_staking` separately to minimize size of snapshot
const {
pooled_staking: derivedPooledStaking,
non_evm_staking: derivedNonEvmStaking,
...derivedStateWithoutPooledStaking
} = derivedState;
expect(derivedPooledStaking).toStrictEqual({
Expand All @@ -2630,6 +2749,7 @@ describe('EarnController', () => {
},
isEligible: true,
});
expect(derivedNonEvmStaking).toStrictEqual({});
expect(derivedStateWithoutPooledStaking).toMatchInlineSnapshot(`
Object {
"lastUpdated": 0,
Expand Down Expand Up @@ -2699,9 +2819,10 @@ describe('EarnController', () => {
'persist',
);

// Compare `pooled_staking` separately to minimize size of snapshot
// Compare `pooled_staking` and `non_evm_staking` separately to minimize size of snapshot
const {
pooled_staking: derivedPooledStaking,
non_evm_staking: derivedNonEvmStaking,
...derivedStateWithoutPooledStaking
} = derivedState;
expect(derivedPooledStaking).toStrictEqual({
Expand All @@ -2721,6 +2842,7 @@ describe('EarnController', () => {
},
isEligible: true,
});
expect(derivedNonEvmStaking).toStrictEqual({});
expect(derivedStateWithoutPooledStaking).toMatchInlineSnapshot(`
Object {
"lending": Object {
Expand Down Expand Up @@ -2789,9 +2911,10 @@ describe('EarnController', () => {
'usedInUi',
);

// Compare `pooled_staking` separately to minimize size of snapshot
// Compare `pooled_staking` and `non_evm_staking` separately to minimize size of snapshot
const {
pooled_staking: derivedPooledStaking,
non_evm_staking: derivedNonEvmStaking,
...derivedStateWithoutPooledStaking
} = derivedState;
expect(derivedPooledStaking).toStrictEqual({
Expand All @@ -2811,6 +2934,7 @@ describe('EarnController', () => {
},
isEligible: true,
});
expect(derivedNonEvmStaking).toStrictEqual({});
expect(derivedStateWithoutPooledStaking).toMatchInlineSnapshot(`
Object {
"lending": Object {
Expand Down
71 changes: 71 additions & 0 deletions packages/earn-controller/src/EarnController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,25 @@ export type LendingState = {
isEligible: boolean;
};

/**
* State for a single non-EVM staking chain.
* This is a generic structure that can be used for any non-EVM chain (TRON, Solana, etc.)
*/
export type NonEvmStakingChainState = {
/** The annual percentage yield as a decimal string (e.g., "3.35" for 3.35%) */
apy: string;
/** Timestamp of when the APY was last fetched */
lastUpdated: number;
};

/**
* State for non-EVM staking across multiple chains.
* Keyed by chain identifier (string to support non-EVM chain IDs).
*/
export type NonEvmStakingState = {
[chainId: string]: NonEvmStakingChainState;
};

type StakingTransactionTypes =
| TransactionType.stakingDeposit
| TransactionType.stakingUnstake
Expand Down Expand Up @@ -126,6 +145,12 @@ const earnControllerMetadata: StateMetadata<EarnControllerState> = {
includeInDebugSnapshot: false,
usedInUi: true,
},
non_evm_staking: {
includeInStateLogs: true,
persist: true,
includeInDebugSnapshot: false,
usedInUi: true,
},
lastUpdated: {
includeInStateLogs: true,
persist: false,
Expand All @@ -136,8 +161,11 @@ const earnControllerMetadata: StateMetadata<EarnControllerState> = {

// === State Types ===
export type EarnControllerState = {
// eslint-disable-next-line @typescript-eslint/naming-convention
pooled_staking: PooledStakingState;
lending: LendingState;
// eslint-disable-next-line @typescript-eslint/naming-convention
non_evm_staking: NonEvmStakingState;
lastUpdated: number;
};

Expand Down Expand Up @@ -207,6 +235,11 @@ export const DEFAULT_POOLED_STAKING_CHAIN_STATE = {
vaultApyAverages: DEFAULT_POOLED_STAKING_VAULT_APY_AVERAGES,
};

export const DEFAULT_NON_EVM_STAKING_CHAIN_STATE: NonEvmStakingChainState = {
apy: '0',
lastUpdated: 0,
};

/**
* Gets the default state for the EarnController.
*
Expand All @@ -222,6 +255,7 @@ export function getDefaultEarnControllerState(): EarnControllerState {
positions: [DEFAULT_LENDING_POSITION],
isEligible: false,
},
non_evm_staking: {},
lastUpdated: 0,
};
}
Expand Down Expand Up @@ -804,6 +838,43 @@ export class EarnController extends BaseController<
}
}

/**
* Refreshes the APY for a non-EVM staking chain.
* This method that can be used for any non-EVM chain
* The consumer provides a fetcher function that returns the APY for the specified chain.
*
* @param options - The options for refreshing the non-EVM staking APY.
* @param options.chainId - The chain identifier.
* @param options.apyFetcher - An async function that fetches and returns the APY as a decimal string.
* @returns A promise that resolves when the APY has been updated.
*/
async refreshNonEvmStakingApy({
chainId,
apyFetcher,
}: {
chainId: string;
apyFetcher: () => Promise<string>;
}): Promise<void> {
const apy = await apyFetcher();

this.update((state) => {
state.non_evm_staking[chainId] = {
apy,
lastUpdated: Date.now(),
};
});
}

/**
* Gets the non-EVM staking APY for a specific chain.
*
* @param chainId - The chain identifier.
* @returns The APY for the specified chain, or undefined if not found.
*/
getNonEvmStakingApy(chainId: string): string | undefined {
return this.state.non_evm_staking[chainId]?.apy;
}

/**
* Gets the lending position history for the current account.
*
Expand Down
6 changes: 6 additions & 0 deletions packages/earn-controller/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export type {
PooledStakingState,
LendingState,
NonEvmStakingState,
NonEvmStakingChainState,
LendingMarketWithPosition,
LendingPositionWithMarket,
LendingPositionWithMarketReference,
Expand All @@ -15,6 +17,7 @@ export type {
export {
controllerName,
getDefaultEarnControllerState,
DEFAULT_NON_EVM_STAKING_CHAIN_STATE,
EarnController,
} from './EarnController';

Expand All @@ -36,6 +39,9 @@ export {
selectLendingMarketsByTokenAddress,
selectLendingMarketsByChainIdAndOutputTokenAddress,
selectLendingMarketsByChainIdAndTokenAddress,
selectNonEvmStaking,
selectNonEvmStakingForChainId,
selectNonEvmStakingApyForChainId,
} from './selectors';

export {
Expand Down
Loading
Loading