Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
981ab1f
fix(#471): expose window.__queryClient only outside production
TheCodingChef-eth Sep 24, 2026
4fa8506
fix(#472): render tvl directly instead of formatCredits double-format
TheCodingChef-eth Sep 24, 2026
076c44c
test(#472): align PlatformStats mock with formatted tvl / credit velo…
TheCodingChef-eth Sep 24, 2026
0f02e38
test(#472): regenerate PlatformStats snapshot ($3,500, 8500.0M XLM)
TheCodingChef-eth Sep 24, 2026
c1028ba
fix(#473): stop excluding tests from tsc type-checking
TheCodingChef-eth Sep 24, 2026
14c7962
fix(#473): widen SkeletonBar w prop to accept responsive object
TheCodingChef-eth Sep 24, 2026
2116f89
fix(#473): import useEffect in Navbar (missing from import)
TheCodingChef-eth Sep 24, 2026
7471281
fix(#473): remove duplicate stepRef declaration in useLockFlow
TheCodingChef-eth Sep 24, 2026
ab3e847
fix(#473): export FarmStore type from farmStore for tests
TheCodingChef-eth Sep 24, 2026
18f3995
test(#473): type BoostModal wallet mock (signTransaction, mutation cast)
TheCodingChef-eth Sep 24, 2026
f75e271
test(#473): type UnlockModal FarmStore selectors and unlockAssets mocks
TheCodingChef-eth Sep 24, 2026
daf3725
test(#473): cast process.env in validateEnv tests
TheCodingChef-eth Sep 24, 2026
7f584be
test(#473): type fetchLeaderboardMock as vi.fn in useLeaderboard tests
TheCodingChef-eth Sep 24, 2026
8c1191c
test(#473): add beforeEach import and typed wallet/toast mocks
TheCodingChef-eth Sep 24, 2026
ac88a69
test(#473): add networkName/isNetworkMismatch and toast mock typing
TheCodingChef-eth Sep 24, 2026
59dbf9f
test(#473): use Buffer.alloc for StrKey seeds in feeBumpGuard tests
TheCodingChef-eth Sep 24, 2026
a71b75d
test(#473): cast SorobanAuthorizationEntry fixtures in auth tests
TheCodingChef-eth Sep 24, 2026
fa6e5cf
test(#473): use Buffer.alloc for StrKey seeds in feebump tests
TheCodingChef-eth Sep 24, 2026
cf85041
test(#473): use Buffer.alloc for StrKey seeds in history tests
TheCodingChef-eth Sep 24, 2026
f0a0f6c
test(#473): destructure rpcServer from makeService in network-guard t…
TheCodingChef-eth Sep 24, 2026
630214d
test(#473): use Buffer.alloc for StrKey seeds in soroban tests
TheCodingChef-eth Sep 24, 2026
48dd67a
test(#475): seed factory + simulation account via webServer env
TheCodingChef-eth Sep 24, 2026
7cddb26
test(#475): seed farm e2e at the network layer, drop __queryClient
TheCodingChef-eth Sep 24, 2026
d7cae34
fix(#475): store locked_at in epoch ms in POSITION_XDR fixture
TheCodingChef-eth Sep 24, 2026
d166ed8
Merge branch 'main' into fix/issues-471-472-473-475
SweetBoy-eth Sep 25, 2026
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
170 changes: 78 additions & 92 deletions e2e/farm.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { type Page } from '@playwright/test';
import { Networks, TransactionBuilder } from '@stellar/stellar-sdk';
import { test, expect, TEST_PUBLIC_KEY, TEST_ADDRESS_DISPLAY } from './mocks/freighter';
import { test, expect, TEST_ADDRESS_DISPLAY } from './mocks/freighter';

// Pre-computed XDR constants (generated with @stellar/stellar-sdk)
// Pools ScVal XDR: scvVec([scvMap({ id: 'pool-xlm', contract_address: '...', asset_code: 'XLM', ... })])
const POOLS_XDR =
'AAAAEAAAAAEAAAABAAAAEQAAAAEAAAAKAAAADwAAAAJpZAAAAAAADgAAAAhwb29sLXhsbQAAAA8AAAAQY29udHJhY3RfYWRkcmVzcwAAAA4AAAA4Q0FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUQyS00AAAAPAAAACmFzc2V0X2NvZGUAAAAAAA4AAAADWExNAAAAAA8AAAAJaXNfbmF0aXZlAAAAAAAAAAAAAAEAAAAPAAAACmRhaWx5X3JhdGUAAAAAAAoAAAAAAAAAAAAAAAAAAYagAAAADwAAAA9taW5fbG9ja19wZXJpb2QAAAAABQAAAAAACTqAAAAADwAAAAx0b3RhbF9sb2NrZWQAAAAKAAAAAAAAAAAAAAAXSHboAAAAAA8AAAALdG90YWxfdXNlcnMAAAAAAwAAAAUAAAAPAAAACWlzX2FjdGl2ZQAAAAAAAAAAAAABAAAADwAAAApjcmVhdGVkX2F0AAAAAAAFAAAAAAAAAAA=';

// User position ScVal XDRs — locked (10 XLM at FIXED_NOW_MS-60s) and empty.
// locked_at / unlockable_at are epoch MILLISECONDS (FarmPosition contract:
// unlockAvailableAt = lockedAt + lockPeriodSeconds * 1000).
const POSITION_XDR =
'AAAAEQAAAAEAAAAFAAAADgAAAAZhbW91bnQAAAAAAAUAAAAABfXhAAAAAA4AAAAHY3JlZGl0cwAAAAAFAAAAAAAAAAAAAAAOAAAACWlzX2xvY2tlZAAAAAAAAAAAAAABAAAADgAAAAlsb2NrZWRfYXQAAAAAAAAFAAABl3Qf8aAAAAAOAAAADXVubG9ja2FibGVfYXQAAAAAAAAFAAABl5gsdaA=';
const EMPTY_POSITION_XDR =
'AAAAEQAAAAEAAAAFAAAADgAAAAZhbW91bnQAAAAAAAUAAAAAAAAAAAAAAA4AAAAHY3JlZGl0cwAAAAAFAAAAAAAAAAAAAAAOAAAACWlzX2xvY2tlZAAAAAAAAAAAAAAAAAAADgAAAAlsb2NrZWRfYXQAAAAAAAAFAAAAAAAAAAAAAAAOAAAADXVubG9ja2FibGVfYXQAAAAAAAAFAAAAAAAAAAA=';

// Account LedgerEntry XDR for getLedgerEntries mock response
const ACCOUNT_XDR =
'AAAAAAAAAAA2Ien4u6Ar2/msLbY4G0lyInC8QbRR+8jvZwBJ4mqxggAAABdIdugAAAAAAEmWAtIAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAA';
Expand All @@ -27,6 +35,8 @@ const SUCCESS_META_XDR = 'AAAAAAAAAAA=';
const FIXED_NOW_MS = 1_750_000_000_000;
const CONNECT_WALLET_BUTTON_NAME = /connect (freighter|wallet)/i;

type PositionState = 'empty' | 'locked';

function getSimulatedFunctionName(transactionXdr?: string): string | null {
if (!transactionXdr) return null;

Expand All @@ -49,9 +59,18 @@ function getSimulatedFunctionName(transactionXdr?: string): string | null {
}

// ── RPC fetch mock ──────────────────────────────────────────────────────────

async function mockSorobanRpc(page: Page): Promise<void> {
//
// Seeds pool + position data at the network layer (issue #475) so farm.spec
// no longer depends on `window.__queryClient`. get_pools / get_user_position
// return pre-computed XDRs; sendTransaction flips the mock position state so
// the post-mutation QueryClient invalidation refetches the updated value.

async function mockSorobanRpc(
page: Page,
options: { initialPosition?: PositionState } = {},
): Promise<void> {
let submittedTransactionXdr = '';
let positionState: PositionState = options.initialPosition ?? 'empty';

await page.route('**/horizon-testnet.stellar.org/accounts/**', async (route) => {
await route.fulfill({
Expand All @@ -72,6 +91,28 @@ async function mockSorobanRpc(page: Page): Promise<void> {
let result: unknown;

switch (body.method) {
case 'getHealth':
result = { status: 'OK', latestLedger: 100 };
break;

case 'getLatestLedger':
// Intentionally missing headerXdr/metadataXdr: parseRawLatestLedger
// throws, getCreditVelocity / useSorobanEvents catch → "0" / abort
// polling. Avoids a real-network continue() while staying hermetic.
result = { sequence: 100, id: 'b'.repeat(64) };
break;

case 'getEvents':
result = {
latestLedger: 100,
oldestLedger: 1,
latestLedgerCloseTime: '0',
oldestLedgerCloseTime: '0',
cursor: '',
events: [],
};
break;

case 'getLedgerEntries':
result = {
entries: [
Expand All @@ -85,37 +126,49 @@ async function mockSorobanRpc(page: Page): Promise<void> {
};
break;

case 'simulateTransaction':
case 'simulateTransaction': {
const functionName = getSimulatedFunctionName(body.params?.transaction);
const auth =
functionName === 'lock_assets'
? [LOCK_ASSETS_AUTH_XDR]
: functionName === 'unlock_assets'
? [UNLOCK_ASSETS_AUTH_XDR]
: [];
// Works for get_pools, get_user_position, and unlock_assets alike.
// get_user_position parsing ignores a Vec retval and returns null (no position),
// so positions come exclusively from the QueryClient seed in tests.
// Function-specific retval: get_pools → POOLS_XDR,
// get_user_position → POSITION_XDR / EMPTY_POSITION_XDR,
// lock/unlock (and anything else) → scvVoid.
let retvalXdr = 'AAAAAQ==';
if (functionName === 'get_pools') retvalXdr = POOLS_XDR;
else if (functionName === 'get_user_position') {
retvalXdr = positionState === 'locked' ? POSITION_XDR : EMPTY_POSITION_XDR;
}
result = {
id: String(body.id),
transactionData: SOROBAN_DATA_XDR,
results: [{ xdr: 'AAAAAQ==', auth }], // scvVoid
results: [{ xdr: retvalXdr, auth }],
minResourceFee: '100',
events: [],
cost: { cpuInsns: '1000', memBytes: '1000' },
latestLedger: 100,
};
break;
}

case 'sendTransaction':
case 'sendTransaction': {
submittedTransactionXdr = body.params?.transaction ?? '';
const sentFn = getSimulatedFunctionName(submittedTransactionXdr);
// Flip mock position state BEFORE getTransaction SUCCESS so the
// mutation onSuccess → invalidateQueries refetch sees the new value.
if (sentFn === 'lock_assets') positionState = 'locked';
else if (sentFn === 'unlock_assets') positionState = 'empty';
result = {
hash: 'a'.repeat(64),
status: 'PENDING',
latestLedger: 100,
latestLedgerCloseTime: '0',
};
break;
}

case 'getTransaction':
result = {
Expand Down Expand Up @@ -145,52 +198,6 @@ async function mockSorobanRpc(page: Page): Promise<void> {
});
}

// ── QueryClient seed helpers ────────────────────────────────────────────────

const MOCK_POOL = {
id: 'pool-xlm',
contractAddress: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM',
asset: { code: 'XLM', isNative: true },
dailyRate: '0.0000001',
minLockPeriod: 604800, // 7 days in seconds
totalLocked: '10.0000000',
totalUsers: 5,
isActive: true,
createdAt: 0,
};

function makeMockPosition(lockedAtMs: number) {
return {
user: TEST_PUBLIC_KEY,
poolId: 'pool-xlm',
amount: '10.0000000',
lockedAt: lockedAtMs,
credits: '0',
isLocked: true,
unlockableAt: lockedAtMs + 604_800_000,
};
}

async function seedPools(page: Page): Promise<void> {
await page.evaluate((pool) => {
const qc = (window as any).__queryClient;
if (qc) qc.setQueryData(['pools'], [pool]);
}, MOCK_POOL);
}

async function seedPosition(page: Page, lockedAtMs: number): Promise<void> {
const pos = makeMockPosition(lockedAtMs);
await page.evaluate(
({ pool, position, pubKey }) => {
const qc = (window as any).__queryClient;
if (!qc) return;
qc.setQueryData(['pools'], [pool]);
qc.setQueryData(['userPosition', 'all', pubKey], [{ pool, position }]);
},
{ pool: MOCK_POOL, position: pos, pubKey: TEST_PUBLIC_KEY },
);
}

async function connectWallet(page: Page): Promise<void> {
await page.getByRole('button', { name: CONNECT_WALLET_BUTTON_NAME }).first().click();
await page.waitForFunction(
Expand Down Expand Up @@ -220,15 +227,14 @@ test.describe('Farm E2E', () => {
});

test('2 · deposit — modal accepts 10 XLM and submits', async ({ page }) => {
await mockSorobanRpc(page);
// Start empty; sendTransaction of lock_assets flips the mock position →
// locked so the post-success QueryClient invalidation refetches POSITION_XDR.
await mockSorobanRpc(page, { initialPosition: 'empty' });
await page.goto('/farm');
await page.waitForLoadState('networkidle');
await connectWallet(page);

// Seed pool data so the Farm pools section renders a row
await seedPools(page);

// Wait for pool row with Deposit button
// Wait for pool row with Deposit button (seeded via get_pools XDR mock)
const depositBtn = page.getByRole('button', { name: /^\+ deposit$/i }).first();
await expect(depositBtn).toBeVisible({ timeout: 8_000 });
await depositBtn.click();
Expand All @@ -248,16 +254,15 @@ test.describe('Farm E2E', () => {
// Chakra renders a spinner; just ensure the button still exists
});

// After the 1.5 s stub delay, seed a position so "My earnings" shows 10 XLM
// Give the lock flow time to submit + flip mock position + invalidate.
await page.waitForTimeout(1_800);
await seedPosition(page, FIXED_NOW_MS - 60_000);

// My earnings row now shows the staked amount
await expect(page.getByText('10.0000000').last()).toBeVisible({ timeout: 5_000 });
});

test('3 · countdown visible — Unlock button is disabled before lock period', async ({ page }) => {
await mockSorobanRpc(page);
await mockSorobanRpc(page, { initialPosition: 'locked' });

// Set the clock to a fixed point so the countdown is always non-zero
await page.clock.setFixedTime(new Date(FIXED_NOW_MS));
Expand All @@ -266,9 +271,6 @@ test.describe('Farm E2E', () => {
await page.waitForLoadState('networkidle');
await connectWallet(page);

// Seed a position locked 1 minute ago (7-day lock period → ~7 days remaining)
await seedPosition(page, FIXED_NOW_MS - 60_000);

// Countdown label is visible and contains time-remaining text (e.g. "6d …").
// Both the pool row (FarmPoolRow) and the earnings row (EarningRow) render
// this position's countdown independently, so two elements legitimately
Expand All @@ -283,21 +285,23 @@ test.describe('Farm E2E', () => {
});

test('4 · unlock available — fast-forward 8 days enables Unlock', async ({ page }) => {
await mockSorobanRpc(page);
await mockSorobanRpc(page, { initialPosition: 'locked' });

await page.clock.setFixedTime(new Date(FIXED_NOW_MS));

await page.goto('/farm');
await page.waitForLoadState('networkidle');
await connectWallet(page);
await seedPosition(page, FIXED_NOW_MS - 60_000);

// Fast-forward 8 days (past the 7-day lock period)
const eightDaysMs = 8 * 24 * 60 * 60 * 1_000;
await page.clock.setFixedTime(new Date(FIXED_NOW_MS + eightDaysMs));

// The useCountdown hook re-evaluates on each render
await page.reload();
// The useCountdown hook re-evaluates on each render; trigger a navigation
// back to /farm so the hook picks up the new Date.now()
await page.goto('/farm');
await page.waitForLoadState('networkidle');
await connectWallet(page);

// Unlock button should now be enabled
const unlockBtn = page.getByRole('button', { name: /^unlock$/i }).first();
Expand All @@ -309,7 +313,9 @@ test.describe('Farm E2E', () => {
});

test('5 · unlock — fill modal, sign, submit; stake drops to 0', async ({ page }) => {
await mockSorobanRpc(page);
// Start locked; sendTransaction of unlock_assets flips the mock position →
// empty so the post-success QueryClient invalidation refetches EMPTY_POSITION_XDR.
await mockSorobanRpc(page, { initialPosition: 'locked' });

// Start with clock 8 days in the future so Unlock is immediately available
const eightDaysMs = 8 * 24 * 60 * 60 * 1_000;
Expand All @@ -318,7 +324,6 @@ test.describe('Farm E2E', () => {
await page.goto('/farm');
await page.waitForLoadState('networkidle');
await connectWallet(page);
await seedPosition(page, FIXED_NOW_MS - 60_000);

// Unlock button should be enabled
const unlockBtn = page.getByRole('button', { name: /^unlock$/i }).first();
Expand All @@ -343,26 +348,7 @@ test.describe('Farm E2E', () => {
page.getByText(/unlock (confirmed|submitted)/i).first(),
).toBeVisible({ timeout: 15_000 });

// After success, update cache to show 0 stake
await page.evaluate(
({ pool, pubKey }) => {
const qc = (window as any).__queryClient;
if (!qc) return;
const emptyPos = {
user: pubKey,
poolId: 'pool-xlm',
amount: '0.0000000',
lockedAt: 0,
credits: '0',
isLocked: false,
unlockableAt: 0,
};
qc.setQueryData(['userPosition', 'all', pubKey], [{ pool, position: emptyPos }]);
},
{ pool: MOCK_POOL, pubKey: TEST_PUBLIC_KEY },
);

// "My earnings" now shows 0.0000000
// "My earnings" now shows 0.0000000 after the position flip + refetch
await expect(page.getByText('0.0000000').last()).toBeVisible({ timeout: 5_000 });
});
});
8 changes: 8 additions & 0 deletions e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ export default defineConfig({
'http://localhost:3000/__mock-leaderboard-api',
NEXT_PUBLIC_MIN_LOCK_PERIOD_SECONDS: '604800',
NEXT_PUBLIC_E2E: 'true',
// Network-level seeding for farm.spec (issue #475): getFactoryPools /
// getUserPosition short-circuit unless a factory contract and a funded
// simulation account are configured. Both are satisfied by the RPC
// mocks in e2e/farm.spec.ts (POOLS_XDR / POSITION_XDR / ACCOUNT_XDR).
NEXT_PUBLIC_FACTORY_CONTRACT_ID:
'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM',
NEXT_PUBLIC_SIMULATION_ACCOUNT:
'GA3CD2PYXOQCXW7ZVQW3MOA3JFZCE4F4IG2FD66I55TQASPCNKYYEFRN',
},
},
});
10 changes: 9 additions & 1 deletion src/app/loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@
import { usePathname } from "next/navigation";
import { Box, Flex, Spinner, Text, type BoxProps } from "@chakra-ui/react";

function SkeletonBar({ w = "100%", h = "16px", delay = 0 }: { w?: BoxProps["w"]; h?: BoxProps["h"]; delay?: number }) {
function SkeletonBar({
w = "100%",
h = "16px",
delay = 0,
}: {
w?: string | { base: string; md: string };
h?: string;
delay?: number;
}) {
return (
<Box
w={w}
Expand Down
2 changes: 1 addition & 1 deletion src/components/BoostModal/BoostModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const useSetBoostMock = vi.mocked(useSetBoost);

const defaultWallet = {
publicKey: "GA3CD2PYXOQCXW7ZVQW3MOA3JFZCE4F4IG2FD66I55TQASPCNKYYEFRN",
walletApi: {} as never,
walletApi: { signTransaction: vi.fn() },
networkName: "TESTNET",
isNetworkMismatch: false,
isConnected: true,
Expand Down
9 changes: 5 additions & 4 deletions src/components/PlatformStats/PlatformStats.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ describe('<PlatformStats /> Layout Component', () => {
it('matches baseline style design snapshot tests', () => {
vi.spyOn(hooks, 'usePlatformStats').mockReturnValue({
data: {
// getPlatformStats returns totalValueLocked already formatted via
// toLocaleString (currency USD), so the component must not re-format it.
tvl: '$42,300,000',
// Production-shaped values: getPlatformStats formats TVL with
// toLocaleString({ style: 'currency' }), and getCreditVelocity returns
// a raw credit-unit count that formatCredits scales for display.
tvl: '$3,500',
activePools: 12,
totalFarmers: 15450,
creditVelocity: '8500000000000'
creditVelocity: '8500000000000',
},
isLoading: false,
isError: false,
Expand Down
2 changes: 1 addition & 1 deletion src/components/PlatformStats/PlatformStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const PlatformStats: React.FC<PlatformStatsProps> = ({ initialData }) =>
<StatLabel color="app.muted">Total Value Locked</StatLabel>
<Skeleton isLoaded={!isLoading || !!stats} startColor="app.border" endColor="app.surfaceHover">
<StatNumber fontSize="2xl" fontWeight="extrabold" color="app.accent">
{stats ? stats.tvl : '—'}
{stats ? stats.tvl : '$0'}
</StatNumber>
</Skeleton>
</Stat>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ exports[`<PlatformStats /> Layout Component > matches baseline style design snap
<dd
class="chakra-stat__number css-okwwd4"
>
$42,300,000
$3,500
</dd>
</div>
</dl>
Expand Down
Loading