diff --git a/docs/LpPositionManagement.md b/docs/LpPositionManagement.md new file mode 100644 index 00000000..33e29cee --- /dev/null +++ b/docs/LpPositionManagement.md @@ -0,0 +1,103 @@ +# LP Position Management (Portfolio → Liquidity) + +The **Liquidity** tab on `/profile` (and `/profile/:wallet`, read-only) lets a +liquidity provider see their active LP positions and manage them (#1030). + +## What it shows + +- **Total LP earnings** card: unclaimed rewards + rewards already claimed from + the active positions, plus a position count. +- One row per active position: pool / key name, contributed amount, pool + share %, accrued (unclaimed) rewards, and lock status. +- Per-position actions, rendered only when the connected Stellar signer owns + the positions: **Add liquidity**, **Claim rewards**, **Remove liquidity**. + +## Where the data comes from + +| Concern | Source | Code | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| Positions, rewards, lock | `GET /lp/positions?wallet=` (accesslayer-server #980) | `services/lpPositions.service.ts` | +| Pool size, APR estimate | `GET /lp/pool/:keyId` (accesslayer-server #980) | `services/lpPositions.service.ts` | +| Spendable XLM | Account ledger entry over Soroban RPC (`VITE_STELLAR_RPC_URL`) | `services/stellarAccount.service.ts` | +| Add / claim / remove | Creator Keys contract `add_liquidity` / `claim_lp_rewards` / `remove_liquidity` (accesslayer-contracts #1002, PR #1004) | `services/lpContract.service.ts` | + +Hooks live in `hooks/useLpPositions.ts`; pure math lives in +`utils/lpPositions.utils.ts`. + +### Expected API shapes + +The server endpoints are specified in accesslayer-server #980 but were not +merged when this UI was built. The client expects the following shape and +validates every field. Amounts are **stroops as integer strings** (i128 in +the contract); numbers are accepted only when they are safe integers. + +```jsonc +// GET /lp/positions?wallet=G... → APIResponse<{ positions: [...] }> (a bare array also works) +{ + "lpId": "7", // contract lp_id (u64) + "keyId": "C...", // contract key_id (Address) + "keyName": "Alpha Key", // optional, falls back to a shortened keyId + "creatorId": "alpha", // optional, enables the /creator/:id link + "contribution": "250000000", + "share": 2500, // optional, contract basis points + "poolTotalLiquidity": "1000000000", // optional, preferred for an exact share + "pendingRewards": "12345", // missing/invalid → shown as "Unavailable" + "claimedRewards": "100", // optional, lifetime claimed from this position + "unlocksAt": "2030-01-01T00:00:00Z", // optional; ISO, epoch s, or epoch ms +} + +// GET /lp/pool/:keyId → APIResponse<{ keyId, totalLiquidity, aprBps }> +``` + +Records missing `lpId`, `keyId`, or a valid `contribution` are dropped, as are +closed positions (`contribution = 0`, which is how the contract reports them). + +## Rules the UI follows + +- **Precision.** All amounts are `bigint` stroops end to end and are rendered + with 7 decimals via `formatXlm`, so tiny rewards never display as `0.00`. +- **Pool share** is `contribution / poolTotalLiquidity` in integer math, + floored to 0.01%. A non-zero share under 0.01% reads `<0.01%`. If the pool + total is missing, the contract's basis-point `share` is used instead. +- **Earnings total** de-duplicates by `lpId` and is withheld ("Unavailable") + when any position's rewards can't be read, rather than showing a partial sum. +- **Add liquidity** validates format, > 0, ≤ 7 decimals, and ≤ spendable XLM + (balance minus account reserve and selling liabilities). The mutation reads + the balance again right before signing, so a balance that dropped after the + modal opened is caught. +- **Reward rate preview** shows the API's APR estimate and the pool share the + deposit would receive using the contract formula + `amount / (total_liquidity + amount)`. No reward-rate formula is invented + client-side; if the API has no APR the preview says "Unavailable". +- **Claim** simulates `claim_lp_rewards` first. If the contract would pay out + zero, the wallet is never prompted. +- **Remove** is disabled while `unlocksAt` is in the future, with a live + countdown (`2d 14h 32m`). An unparseable `unlocksAt` blocks removal. Before + signing, the mutation refetches positions and re-checks the lock, and the + contract simulation has the final say. +- **Confirmation.** Success toasts (with tx hash and Stellar Expert link) + appear only after `getTransaction` reports `SUCCESS`. A transaction that + lands but fails on-chain surfaces as an error. +- **Refresh.** After a confirmed transaction the hooks invalidate + `queryKeys.lp.positions(wallet)`, `queryKeys.lp.pool(keyId)`, and + `queryKeys.wallet.xlmBalance(wallet)`. Positions also refetch every 30s and + when a lock countdown reaches zero. + +## Known limitations + +- **Lock period:** the LP reward contract in PR #1004 has no lock. `unlocksAt` + is honoured if the API provides it. Without it, removal is allowed and the + contract decides. +- **Asset:** the contract does not name the deposited token. The UI assumes + XLM (stroops, 7 decimals) like every other amount in the app. +- **Entry point:** "Add liquidity" is offered per existing pool. Opening a + first position in a new pool needs an entry point on the creator page, + which is outside this tab's scope. +- **Base reserve** is fixed at 0.5 XLM (`BASE_RESERVE_STROOPS`), the current + value on mainnet and testnet. + +## Configuration + +Requires `VITE_STELLAR_RPC_URL` and `VITE_CREATOR_KEYS_CONTRACT_ID` (see +`.env.example`). Without them, reads from the API still work but actions show +a clear "not configured" error. diff --git a/src/components/common/AddLiquidityDialog.tsx b/src/components/common/AddLiquidityDialog.tsx new file mode 100644 index 00000000..9ec6f703 --- /dev/null +++ b/src/components/common/AddLiquidityDialog.tsx @@ -0,0 +1,274 @@ +import React, { useState } from 'react'; +import { LoaderCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useLpPool, useSpendableXlmBalance } from '@/hooks/useLpPositions'; +import type { LpPosition } from '@/services/lpPositions.service'; +import { + formatLpAmount, + formatPoolShare, + formatProjectedPoolShare, + stroopsToXlmInput, + validateLpAmountInput, +} from '@/utils/lpPositions.utils'; +import { bpsToPercent } from '@/utils/numberFormat.utils'; + +export interface AddLiquidityDialogProps { + /** Pool context; the dialog is open while this is non-null. */ + position: LpPosition | null; + /** Connected Stellar address whose balance funds the deposit. */ + wallet: string; + onClose: () => void; + /** Submits the raw input; the mutation re-validates against a fresh balance. */ + onSubmit: (amountInput: string) => void; + isSubmitting: boolean; + /** Message from the last failed submission, shown inline. */ + submitError?: string | null; +} + +/** + * Add-liquidity modal (#1030). + * + * Validates the amount against the wallet's spendable XLM (read from chain) + * before enabling submit, previews the pool's current APR estimate from the + * LP API, and shows the pool share this deposit would receive using the + * contract's own `add_liquidity` share formula. + */ +const AddLiquidityDialog: React.FC = ({ + position, + wallet, + onClose, + onSubmit, + isSubmitting, + submitError, +}) => { + const open = position !== null; + const [amountInput, setAmountInput] = useState(''); + const [touched, setTouched] = useState(false); + const balanceQuery = useSpendableXlmBalance(wallet, open); + const poolQuery = useLpPool(position?.keyId, open); + + const availableStroops = balanceQuery.data ?? null; + const validation = validateLpAmountInput(amountInput, { availableStroops }); + const showError = touched && !validation.ok && !balanceQuery.isLoading; + const errorId = 'add-liquidity-amount-error'; + + const pool = poolQuery.data; + const poolTotal = + pool?.totalLiquidityStroops ?? + position?.poolTotalLiquidityStroops ?? + null; + + const handleOpenChange = (next: boolean) => { + if (next || isSubmitting) return; + setAmountInput(''); + setTouched(false); + onClose(); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + setTouched(true); + if (!validation.ok || isSubmitting) return; + onSubmit(amountInput.trim()); + }; + + return ( + + + + Add liquidity + + Deposit XLM into the {position?.keyName} pool to earn a share + of its trading fees. + + + + {position && ( +
+
+
+ Current contribution + + {formatLpAmount(position.contributionStroops)} + +
+
+ Current pool share + + {formatPoolShare(position)} + +
+
+ +
+
+ + + Available:{' '} + {balanceQuery.isLoading + ? 'Loading…' + : balanceQuery.isError + ? 'Unavailable' + : formatLpAmount(availableStroops)} + +
+
+ { + setAmountInput(event.target.value); + setTouched(true); + }} + disabled={isSubmitting} + aria-invalid={showError || undefined} + aria-describedby={showError ? errorId : undefined} + className="min-w-0 flex-1 rounded-lg border border-white/15 bg-white/5 px-3 py-2 font-mono text-sm text-white outline-none placeholder:text-white/30 focus:border-amber-300/60" + /> + +
+ {showError && !validation.ok && ( + + )} +
+ +
+

+ Reward rate preview +

+ {poolQuery.isLoading ? ( +

Loading pool data…

+ ) : ( +
+
+
Current APR (estimate)
+
+ {pool?.aprBps != null + ? bpsToPercent(pool.aprBps) + : 'Unavailable'} +
+
+
+
Share of pool for this deposit
+
+ {validation.ok + ? formatProjectedPoolShare( + validation.stroops, + poolTotal + ) + : '—'} +
+
+
+ )} +

+ Rewards come from trading fees and change with volume + and total pool size. The APR is an estimate, not a + guarantee. +

+
+ + {submitError && ( +

+ {submitError} +

+ )} +
+ )} + + + + + +
+
+ ); +}; + +export default AddLiquidityDialog; diff --git a/src/components/common/LiquidityPositionsSection.tsx b/src/components/common/LiquidityPositionsSection.tsx new file mode 100644 index 00000000..01bf2aab --- /dev/null +++ b/src/components/common/LiquidityPositionsSection.tsx @@ -0,0 +1,371 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { LoaderCircle, RotateCcw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import EmptyState from '@/components/common/EmptyState'; +import LpEarningsSummaryCard from '@/components/common/LpEarningsSummaryCard'; +import LpPositionsList from '@/components/common/LpPositionsList'; +import AddLiquidityDialog from '@/components/common/AddLiquidityDialog'; +import { useStellarWallet } from '@/hooks/useStellarWallet'; +import { useNowMs } from '@/hooks/useNowMs'; +import { + describeLpTransactionError, + useAddLiquidity, + useClaimLpRewards, + useLpPositions, + useRemoveLiquidity, +} from '@/hooks/useLpPositions'; +import type { LpPosition } from '@/services/lpPositions.service'; +import { + formatLpAmount, + resolveLpLockState, + summarizeLpEarnings, +} from '@/utils/lpPositions.utils'; +import { isOwnWallet } from '@/utils/isOwnWallet'; +import { cn } from '@/lib/utils'; + +export interface LiquidityPositionsSectionProps { + /** + * Wallet from `/profile/:wallet`. When absent this is the viewer's own + * portfolio and positions are loaded for the connected Stellar wallet. + */ + publicWallet?: string; + className?: string; +} + +function PositionSkeleton() { + return ( +