Skip to content
Merged
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
93 changes: 73 additions & 20 deletions app/src/app/deposit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ import {
beginSubmission,
markAwaitingSignature,
markSubmitted,
markPending,
markConfirmed,
markFailed,
markExpired,
findActiveIntent,
type PaymentIntent,
} from '@/lib/paymentIntent';
import { waitForTransaction, TransactionTimedOutError } from '@/services/rpc';

const TIERS = [
{ id: 'flex', label: 'Flex', lock: 'None', lockMonths: 0, multiplier: 1.0, multiplierLabel: '1.00x', exitFee: '0%', minDeposit: 1, apy: 4.2 },
Expand All @@ -24,7 +27,7 @@ const TIERS = [

type TierId = typeof TIERS[number]['id'];
type Step = 1 | 2 | 3 | 4;
type TxStatus = 'pending' | 'confirmed' | 'failed';
type TxStatus = 'pending' | 'confirmed' | 'failed' | 'expired';

function lockExpiry(lockMonths: number): string {
if (lockMonths === 0) return 'No lock';
Expand All @@ -33,6 +36,32 @@ function lockExpiry(lockMonths: number): string {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}

/**
* TODO(#141 follow-up): stands in for `sdk.deposit({ tier: tier.label,
* amount, idempotencyKey })` (sdks/typescript's YieldLadder, built in
* #139) until the app depends on the SDK directly. That wiring is blocked
* here on adding `@yieldladder/sdk`/`@stellar/stellar-sdk` as a real
* `app/package.json` dependency, which needs a `pnpm install` to
* regenerate `app/pnpm-lock.yaml` correctly — this environment can't
* safely run that (see the memory-constrained-box note in the repo's
* contribution history). Everything downstream of the hash this returns
* (`waitForTransaction` below) is real and already wired for the day this
* returns a genuine submission hash instead.
*
* Format-valid (64 hex chars, like a real Stellar transaction hash) so it
* exercises the real confirmation poller end-to-end against the live RPC
* endpoint rather than failing fast on obviously-malformed input.
*/
async function placeholderSubmissionHash(idempotencyKey: string): Promise<string> {
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(idempotencyKey),
);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

function DepositFlow() {
const params = useSearchParams();
const paramTier = params.get('tier') as TierId | null;
Expand Down Expand Up @@ -63,13 +92,10 @@ function DepositFlow() {
setIntent(active);
setAmount(active.amount);
setStep(4);
setTxStatus(
active.status === 'confirmed'
? 'confirmed'
: active.status === 'failed'
? 'failed'
: 'pending',
);
// findActiveIntent already excludes terminal states (confirmed/failed/
// expired — see isTerminal), so a rehydrated intent is always
// mid-flight; anything short of that bucket falls back to 'pending'.
setTxStatus('pending');
if (active.error) setTxError(active.error);
// Intentionally mount-only: this is a one-time rehydration check, not a
// live subscription to tier/address changes.
Expand Down Expand Up @@ -112,23 +138,38 @@ function DepositFlow() {
setIntent(awaiting);

try {
// sdk.deposit({ tier: tier.label, amount, idempotencyKey: awaiting.key }) would go here
const txHash = await new Promise<string>((resolve) =>
setTimeout(() => resolve(`mock-tx-${awaiting.key}`), 2000),
);
const txHash = await placeholderSubmissionHash(awaiting.key);
const submitted = markSubmitted(awaiting, txHash);
setIntent(submitted);
const confirmed = markConfirmed(submitted);

const pending = markPending(submitted);
setIntent(pending);
setTxStatus('pending');

// Real polling against the live Soroban RPC endpoint (issue #141) —
// not a timer. A 20s cap keeps manual testing bearable; production
// callers get waitForTransaction's full default (60s).
await waitForTransaction(txHash, { timeoutMs: 20_000 });

const confirmed = markConfirmed(pending);
setIntent(confirmed);
setTxStatus('confirmed');
} catch (error) {
const failed = markFailed(
awaiting,
error instanceof Error ? error.message : 'Transaction failed',
);
setIntent(failed);
setTxStatus('failed');
setTxError(failed.error ?? 'An error occurred. Please try again.');
if (error instanceof TransactionTimedOutError) {
const expired = markExpired(
awaiting,
'Could not confirm this transaction in time. It may still complete — check back before retrying.',
);
setIntent(expired);
setTxStatus('expired');
setTxError(expired.error ?? '');
} else {
const message = error instanceof Error ? error.message : 'Transaction failed';
const failed = markFailed(awaiting, message);
setIntent(failed);
setTxStatus('failed');
setTxError(failed.error ?? 'An error occurred. Please try again.');
}
} finally {
setSubmitting(false);
}
Expand Down Expand Up @@ -282,6 +323,18 @@ function DepositFlow() {
</button>
</>
)}
{txStatus === 'expired' && (
<>
<div style={s.failIcon}>?</div>
<h2 style={s.cardTitle}>Confirmation Timed Out</h2>
<p style={s.errText}>
{txError || 'Could not confirm this transaction in time.'}
</p>
<button style={s.btnPrimary} type="button" onClick={handleRetry}>
Try Again
</button>
</>
)}
</div>
)}
</main>
Expand Down
4 changes: 4 additions & 0 deletions app/src/hooks/useHarvestHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export function useHarvestHistory(limit = 20): HarvestHistoryData {
});

useEffect(() => {
// Audited for issue #141's stubbed-data pattern (see the identical
// note in useLastHarvest.ts): no real event indexer exists yet to
// wire this to, unlike usePosition's VaultRouter call. Left as an
// explicit, tracked stub.
// TODO(GF-12): Replace with real event indexer queries
const now = Date.now();
const events: HarvestEvent[] = Array.from({ length: limit }, (_, i) => {
Expand Down
6 changes: 6 additions & 0 deletions app/src/hooks/useLastHarvest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export function useLastHarvest(): LastHarvestData {
});

useEffect(() => {
// Audited for issue #141's stubbed-data pattern: unlike usePosition
// (which had a real VaultRouter contract to call and was fixed to
// reflect that), there is no deployed Harvester contract or indexer
// this hook could call yet — the RPC layer here has nothing real to
// wire to until GF-12 lands. Left as an explicit, tracked stub rather
// than aligned with usePosition's fix.
// TODO(GF-12): Replace with Harvester contract Soroban RPC call
const lastTimestamp = Date.now() - 3 * 24 * 60 * 60 * 1000;
const elapsed = Math.floor((Date.now() - lastTimestamp) / 1000);
Expand Down
77 changes: 32 additions & 45 deletions app/src/hooks/usePosition.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useState, useEffect } from 'react';
import { callRpc } from '../services/rpc';

export type Tier = 'Flex' | 'L3' | 'L6' | 'L12';

Expand All @@ -15,12 +14,15 @@ export interface Position {
lockUntil: number | null;
}

const VAULT_CONTRACT: Record<Tier, string | undefined> = {
Flex: process.env.NEXT_PUBLIC_FLEX_VAULT,
L3: process.env.NEXT_PUBLIC_L3_VAULT,
L6: process.env.NEXT_PUBLIC_L6_VAULT,
L12: process.env.NEXT_PUBLIC_L12_VAULT,
};
/**
* A single VaultRouter handles every tier — tier is a call argument to
* `position(address, tier, asset)`, not a separate deployed contract per
* tier (see sdks/typescript's YieldLadder, issue #139). This previously
* read `NEXT_PUBLIC_{TIER}_VAULT` per tier, which doesn't match how the
* contracts are actually deployed.
*/
const VAULT_ROUTER_CONTRACT_ID = process.env.NEXT_PUBLIC_VAULT_ROUTER_CONTRACT_ID;
const ASSET_CONTRACT_ID = process.env.NEXT_PUBLIC_ASSET_CONTRACT_ID;

export function usePosition(address: string | null, tier: Tier) {
const [position, setPosition] = useState<Position | null>(null);
Expand All @@ -30,50 +32,35 @@ export function usePosition(address: string | null, tier: Tier) {
useEffect(() => {
if (!address) {
setPosition(null);
setError(null);
return;
}

const contractId = VAULT_CONTRACT[tier];
if (!contractId) {
setError(`Contract address for ${tier} vault not configured`);
if (!VAULT_ROUTER_CONTRACT_ID || !ASSET_CONTRACT_ID) {
setError('Vault router contract address not configured');
return;
}

let cancelled = false;
setLoading(true);
setError(null);

async function load() {
try {
// simulateTransaction would be used here with the TypeScript SDK.
// For now, query contract data via getLedgerEntries using the
// user's position storage key derived from their address.
const result = await callRpc<{ entries: unknown[] }>(
'getLedgerEntries',
[{ keys: [`${contractId}:position:${address}`] }],
);
if (!cancelled) {
// Parse XDR result once SDK is available; return zeros until then.
void result;
setPosition({
tier,
shares: '0',
principal: '0',
accruedYield: '0',
lockUntil: null,
});
setLoading(false);
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : String(e));
setLoading(false);
}
}
}

load();
return () => { cancelled = true; };
// TODO(#141 follow-up): call VaultRouter.position(address, tier, asset)
// via simulateTransaction — sdks/typescript's YieldLadder.position()
// already implements exactly this (issue #139), which is the
// "preferred" approach issue #141 calls for. Wiring it in here is
// blocked on the app depending on @yieldladder/sdk (or
// @stellar/stellar-sdk directly) for real XDR encode/decode, which
// needs a `pnpm install` to regenerate app/pnpm-lock.yaml correctly —
// this environment can't safely run that (see the identical blocker
// documented on placeholderSubmissionHash in app/deposit/page.tsx).
//
// Previously this returned a hardcoded { shares: '0', principal: '0',
// ... } dressed up as decoded on-chain data. A silently-wrong zero
// balance is worse than an explicit "unavailable" state — it can read
// as "you have nothing here" to a user who actually has a position.
// Surface that honestly instead of fabricating a result.
setLoading(false);
setPosition(null);
setError(
'Position data is not yet available in the dashboard — SDK integration pending (see issue #141 follow-up)',
);
}, [address, tier]);

return { position, loading, error };
Expand Down
31 changes: 31 additions & 0 deletions app/src/lib/paymentIntent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import {
beginSubmission,
markAwaitingSignature,
markSubmitted,
markPending,
markConfirmed,
markFailed,
markExpired,
findActiveIntent,
loadIntent,
isTerminal,
Expand Down Expand Up @@ -170,4 +172,33 @@ describe('paymentIntent (issue #140)', () => {
expect(reloaded).not.toBeNull();
expect(reloaded!.status).toBe('building');
});

it('markPending transitions submitted -> pending without releasing the lock (issue #141)', () => {
const intent = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage);
const building = beginSubmission(intent, storage)!;
const submitted = markSubmitted(building, 'tx-hash-3', storage);

const pending = markPending(submitted, storage);
expect(pending.status).toBe('pending');
expect(isTerminal(pending.status)).toBe(false);

// Lock is still held: a second tab racing the same operation is refused.
const other = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage);
expect(beginSubmission(other, storage)).toBeNull();
});

it('markExpired is terminal and releases the lock so a retry is not blocked forever (issue #141)', () => {
const intent = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage);
const building = beginSubmission(intent, storage)!;
const submitted = markSubmitted(building, 'tx-hash-4', storage);
const pending = markPending(submitted, storage);

const expired = markExpired(pending, 'Timed out waiting for confirmation', storage);
expect(expired.status).toBe('expired');
expect(isTerminal(expired.status)).toBe(true);

const retried = beginSubmission(expired, storage);
expect(retried).not.toBeNull();
expect(retried!.status).toBe('building');
});
});
Loading
Loading