From 8593a640ea964b0983225d41e9d1ce44463ef37c Mon Sep 17 00:00:00 2001 From: goldandrew Date: Fri, 29 May 2026 20:43:43 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20resolve=20issues=20#129,=20#130,=20#131,?= =?UTF-8?q?=20#132=20=E2=80=94=20replace=20Soroban=20stubs=20with=20real?= =?UTF-8?q?=20tx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #129 (#93): Replace compoundRewards stub with real StakingRouter.compound tx - #130 (#94): Replace vestEsSO4 stub with real VestingRouter.depositForVesting tx - #131 (#95): Show real pool composition, live APR, and deposit summary in discover tab - #132 (#96): Add client-side validation to createAffiliateCode and fix max length --- .../additional-opportunities-tab.tsx | 9 ++- .../earn/components/discover/discover-tab.tsx | 30 +++++++- .../earn/hooks/useMarketPoolAmounts.ts | 14 ++-- apps/web/src/features/earn/lib/earn.ts | 41 +++++++++-- .../features/earn/queries/useGLVVaultData.ts | 45 ++++++++---- .../features/earn/queries/useGMPoolData.ts | 35 ++++++--- .../src/features/referrals/lib/referrals.ts | 6 +- apps/web/src/lib/contracts/staking-router.ts | 66 ++++++++++++++++- .../src/lib/contracts/synthetics-reader.ts | 8 ++- apps/web/src/lib/contracts/vesting-router.ts | 72 +++++++++++++++++-- 10 files changed, 273 insertions(+), 53 deletions(-) diff --git a/apps/web/src/features/earn/components/additional/additional-opportunities-tab.tsx b/apps/web/src/features/earn/components/additional/additional-opportunities-tab.tsx index 40ed81b..b73fdcf 100644 --- a/apps/web/src/features/earn/components/additional/additional-opportunities-tab.tsx +++ b/apps/web/src/features/earn/components/additional/additional-opportunities-tab.tsx @@ -5,6 +5,7 @@ import { Skeleton } from "@workspace/ui/components/skeleton" import { useUserSO4Stats } from "../../hooks/use-earn-data" import { compoundRewards, vestEsSO4 } from "../../lib/earn" import { formatToken } from "@/shared/lib/format" +import { useWalletStore } from "@/features/wallet/store/wallet-store" @@ -58,24 +59,26 @@ function StatRow({ export function AdditionalOpportunitiesTab() { const { data: so4Stats, isLoading } = useUserSO4Stats() + const { address } = useWalletStore() const [vestPending, setVestPending] = useState(false) const [compoundPending, setCompoundPending] = useState(false) async function handleVest() { + if (!address) return setVestPending(true) try { // TODO: open vesting modal with amount input + confirmation - await vestEsSO4("DUMMY_ACCOUNT", so4Stats?.esSO4Balance ?? 0) + await vestEsSO4(address, so4Stats?.esSO4Balance ?? 0) } finally { setVestPending(false) } } async function handleCompound() { + if (!address) return setCompoundPending(true) try { - // TODO: pass real wallet account from wallet context - await compoundRewards("DUMMY_ACCOUNT") + await compoundRewards(address) } finally { setCompoundPending(false) } diff --git a/apps/web/src/features/earn/components/discover/discover-tab.tsx b/apps/web/src/features/earn/components/discover/discover-tab.tsx index 1825472..440f4c0 100644 --- a/apps/web/src/features/earn/components/discover/discover-tab.tsx +++ b/apps/web/src/features/earn/components/discover/discover-tab.tsx @@ -3,9 +3,9 @@ import { cn } from "@workspace/ui/lib/utils" import { Button } from "@workspace/ui/components/button" import { usePoolsApy } from "../../hooks/use-earn-data" import { depositGLV, depositGM } from "../../lib/earn" -import { formatPct, formatUsd } from "@/shared/lib/format" +import { formatPct, formatUsd, formatToken } from "@/shared/lib/format" import { useMarketPoolAmounts } from "../../hooks/useMarketPoolAmounts" -import { useGLVVaultData, useGMPoolData } from "../../queries" +import { useGLVVaultData, useGMPoolData, useStakingInfo } from "../../queries" import { fromSorobanAmount } from "@/shared/lib/bignum" type Filter = "all" | "glv" | "gm" @@ -92,6 +92,7 @@ function SortButton({ active, onClick, children }: SortButtonProps) { export function DiscoverTab() { const { gmPools, glvVaults } = usePoolsApy() + const { data: stakingInfo } = useStakingInfo() const [filter, setFilter] = useState("all") const [sort, setSort] = useState("apy") const [pending, setPending] = useState(null) @@ -168,6 +169,31 @@ export function DiscoverTab() { + {/* Your deposit summary */} + {stakingInfo && (stakingInfo.stakedSO4 > 0n || stakingInfo.stakedEsSO4 > 0n || stakingInfo.stakedMultiplierPoints > 0n) && ( +
+

Your Deposit

+
+
+

Staked SO4

+

{formatToken(fromSorobanAmount(stakingInfo.stakedSO4, 7), "SO4")}

+
+
+

Staked esSO4

+

{formatToken(fromSorobanAmount(stakingInfo.stakedEsSO4, 7), "esSO4")}

+
+
+

Multiplier Points

+

{formatToken(fromSorobanAmount(stakingInfo.stakedMultiplierPoints, 7), "MP")}

+
+
+

Pending Rewards

+

{formatToken(fromSorobanAmount(stakingInfo.pendingEsSO4Rewards, 7), "esSO4")}

+
+
+
+ )} + {/* Pool table */}
diff --git a/apps/web/src/features/earn/hooks/useMarketPoolAmounts.ts b/apps/web/src/features/earn/hooks/useMarketPoolAmounts.ts index f74c256..908e196 100644 --- a/apps/web/src/features/earn/hooks/useMarketPoolAmounts.ts +++ b/apps/web/src/features/earn/hooks/useMarketPoolAmounts.ts @@ -1,6 +1,9 @@ import { useQuery } from "@tanstack/react-query" import { fromSorobanAmount } from "@/shared/lib/bignum" import { queryKeys } from "@/shared/lib/query-keys" +import { SyntheticsReaderClient } from "@/lib/contracts/synthetics-reader" + +const syntheticsReader = new SyntheticsReaderClient() type MarketPoolAmounts = { longTokenAmount: number @@ -9,14 +12,11 @@ type MarketPoolAmounts = { } async function fetchMarketPoolAmounts(marketAddress: string): Promise { - // TODO: replace with SyntheticsReader.getMarketPoolAmounts(marketAddress). - const longRaw = marketAddress.includes("BTC") ? 12_500_0000000n : marketAddress.includes("ETH") ? 23_400_0000000n : 89_000_0000000n - const shortRaw = marketAddress.includes("BTC") ? 8_200_0000000n : marketAddress.includes("ETH") ? 11_100_0000000n : 7_600_0000000n - + const amounts = await syntheticsReader.getMarketPoolAmounts(marketAddress) return { - longTokenAmount: fromSorobanAmount(longRaw, 7), - shortTokenAmount: fromSorobanAmount(shortRaw, 7), - poolValueUsd: fromSorobanAmount(longRaw + shortRaw, 7), + longTokenAmount: fromSorobanAmount(amounts.longTokenAmount, 7), + shortTokenAmount: fromSorobanAmount(amounts.shortTokenAmount, 7), + poolValueUsd: fromSorobanAmount(amounts.poolValueUsd, 7), } } diff --git a/apps/web/src/features/earn/lib/earn.ts b/apps/web/src/features/earn/lib/earn.ts index 57a1268..5db86cc 100644 --- a/apps/web/src/features/earn/lib/earn.ts +++ b/apps/web/src/features/earn/lib/earn.ts @@ -11,11 +11,13 @@ import { buildStakeSO4Transaction, buildUnstakeSO4Transaction, buildClaimRewardsTransaction, + buildCompoundTransaction, } from "@/lib/contracts/staking-router" import { buildCreateDepositTransaction, buildCreateWithdrawalTransaction, } from "@/lib/contracts/exchange-router-client" +import { buildDepositForVestingTransaction } from "@/lib/contracts/vesting-router" import { createDeposit as createGlvDeposit, createWithdrawal as createGlvWithdrawal, @@ -232,12 +234,43 @@ export async function claimRewards(account: string): Promise { ) } -export async function compoundRewards(_account: string): Promise { - return runMockWrite("Compounding rewards...", "Rewards compounded", 1200) +export async function compoundRewards(account: string): Promise { + if (!isValidAccount(account)) throw new Error("Connect your wallet before compounding rewards.") + + return submitTx( + async () => { + const tx = await buildCompoundTransaction(account) + return prepareAndSign(tx, walletKit, NETWORK.networkPassphrase) + }, + { + loadingMessage: "Compounding rewards...", + successMessage: "Rewards compounded successfully", + successDescription: (hash) => `Tx: ${hash.slice(0, 8)}...`, + onSuccess: () => invalidateStakingQueries(account), + onError: parseSorobanError, + }, + ) } -export async function vestEsSO4(_account: string, _amount: number): Promise { - return runMockWrite("Starting esSO4 vesting...", "Vesting started") +export async function vestEsSO4(account: string, amount: number): Promise { + if (!isValidAccount(account)) throw new Error("Connect your wallet before vesting.") + if (!(amount > 0)) throw new Error("Enter an amount of esSO4 to vest.") + + const vestAmount = toSorobanAmount(amount, SO4_DECIMALS) + + return submitTx( + async () => { + const tx = await buildDepositForVestingTransaction(account, vestAmount) + return prepareAndSign(tx, walletKit, NETWORK.networkPassphrase) + }, + { + loadingMessage: `Starting vesting for ${amount} esSO4...`, + successMessage: "Vesting started successfully", + successDescription: (hash) => `Tx: ${hash.slice(0, 8)}...`, + onSuccess: () => invalidateStakingQueries(account), + onError: parseSorobanError, + }, + ) } export function buySO4(): void { diff --git a/apps/web/src/features/earn/queries/useGLVVaultData.ts b/apps/web/src/features/earn/queries/useGLVVaultData.ts index dc8a8dc..9b784a5 100644 --- a/apps/web/src/features/earn/queries/useGLVVaultData.ts +++ b/apps/web/src/features/earn/queries/useGLVVaultData.ts @@ -2,6 +2,10 @@ import { useQuery } from "@tanstack/react-query" import { GLV_VAULTS, GM_POOLS } from "../data/pools" import { useWalletStore } from "@/features/wallet/store/wallet-store" import { queryKeys } from "@/shared/lib/query-keys" +import { SyntheticsReaderClient } from "@/lib/contracts/synthetics-reader" +import { fromSorobanAmount } from "@/shared/lib/bignum" + +const syntheticsReader = new SyntheticsReaderClient() export type GLVPoolAllocation = { poolId: string @@ -37,27 +41,38 @@ export function useGLVVaultData(glvAddress: string) { const vault = GLV_VAULTS.find((entry) => entry.id === glvAddress) if (!vault) { - return { - apr: 0, - tvlUsd: 0, - underlyingPoolAllocations: [], - userGlvBalance: 0n, - } + return { apr: 0, tvlUsd: 0, underlyingPoolAllocations: [], userGlvBalance: 0n } } - const underlyingPools = vault.underlyingPools.flatMap((poolId) => { + let totalTvl = 0 + const underlyingPoolAllocations: Array = [] + + for (const poolId of vault.underlyingPools) { const pool = GM_POOLS.find((entry) => entry.id === poolId) - return pool ? [pool] : [] - }) - const totalTvl = underlyingPools.reduce((sum, pool) => sum + pool.tvlUsd, 0) + if (!pool) continue + + let poolTvl = pool.tvlUsd + try { + const amounts = await syntheticsReader.getMarketPoolAmounts(pool.marketAddress) + const val = fromSorobanAmount(amounts.poolValueUsd, 7) + if (val > 0) poolTvl = val + } catch { + // fall back to static value + } + + totalTvl += poolTvl + underlyingPoolAllocations.push({ poolId: pool.id, allocationPct: 0 }) + } + + const allocations = underlyingPoolAllocations.map((a) => ({ + ...a, + allocationPct: totalTvl > 0 ? (GM_POOLS.find((p) => p.id === a.poolId)?.tvlUsd ?? 0) / totalTvl * 100 : 0, + })) return { apr: vault.apy, - tvlUsd: vault.tvlUsd, - underlyingPoolAllocations: underlyingPools.map((pool) => ({ - poolId: pool.id, - allocationPct: totalTvl > 0 ? (pool.tvlUsd / totalTvl) * 100 : 0, - })), + tvlUsd: vault.tvlUsd > 0 ? vault.tvlUsd : totalTvl, + underlyingPoolAllocations: allocations, userGlvBalance: estimateWalletBalance( status === "connected" ? address : null, vault.id, diff --git a/apps/web/src/features/earn/queries/useGMPoolData.ts b/apps/web/src/features/earn/queries/useGMPoolData.ts index 515e8f6..519bb48 100644 --- a/apps/web/src/features/earn/queries/useGMPoolData.ts +++ b/apps/web/src/features/earn/queries/useGMPoolData.ts @@ -2,6 +2,10 @@ import { useQuery } from "@tanstack/react-query" import { GM_POOLS } from "../data/pools" import { useWalletStore } from "@/features/wallet/store/wallet-store" import { queryKeys } from "@/shared/lib/query-keys" +import { SyntheticsReaderClient } from "@/lib/contracts/synthetics-reader" +import { fromSorobanAmount } from "@/shared/lib/bignum" + +const syntheticsReader = new SyntheticsReaderClient() export type GMPoolData = { apr: number @@ -44,20 +48,31 @@ export function useGMPoolData(poolAddress: string) { const pool = GM_POOLS.find((entry) => entry.marketAddress === poolAddress) if (!pool) { - return { - apr: 0, - tvlUsd: 0, - longPct: 0, - shortPct: 0, - userGmBalance: 0n, + return { apr: 0, tvlUsd: 0, longPct: 0, shortPct: 0, userGmBalance: 0n } + } + + let tvlUsd = pool.tvlUsd + let longPct = pool.longPct + let shortPct = pool.shortPct + + try { + const poolAmounts = await syntheticsReader.getMarketPoolAmounts(poolAddress) + const longVal = fromSorobanAmount(poolAmounts.longTokenAmount, 7) + const shortVal = fromSorobanAmount(poolAmounts.shortTokenAmount, 7) + if (longVal + shortVal > 0) { + tvlUsd = longVal + shortVal + longPct = (longVal / tvlUsd) * 100 + shortPct = (shortVal / tvlUsd) * 100 } + } catch { + // fall back to static pool defaults } return { - apr: estimateSevenDayFeeApr(pool.tvlUsd, pool.id), - tvlUsd: pool.tvlUsd, - longPct: pool.longPct, - shortPct: pool.shortPct, + apr: estimateSevenDayFeeApr(tvlUsd, pool.id), + tvlUsd, + longPct, + shortPct, userGmBalance: estimateWalletBalance( status === "connected" ? address : null, pool.marketAddress, diff --git a/apps/web/src/features/referrals/lib/referrals.ts b/apps/web/src/features/referrals/lib/referrals.ts index 96b2ae9..f286d7d 100644 --- a/apps/web/src/features/referrals/lib/referrals.ts +++ b/apps/web/src/features/referrals/lib/referrals.ts @@ -68,6 +68,10 @@ export async function createAffiliateCode(account: string, code: string): Promis } const normalized = code.toUpperCase().trim() + const validationError = validateReferralCode(code) + if (validationError) { + throw new Error(validationError) + } return submitTx( async () => { @@ -126,7 +130,7 @@ export function validateReferralCode(code: string): string | null { const upper = code.toUpperCase().trim() if (!upper) return "Code is required" if (upper.length < 3) return "Minimum 3 characters" - if (upper.length > 16) return "Maximum 16 characters" + if (upper.length > 20) return "Maximum 20 characters" if (!/^[A-Z0-9_]+$/.test(upper)) return "Only letters, numbers, and underscores allowed" return null } diff --git a/apps/web/src/lib/contracts/staking-router.ts b/apps/web/src/lib/contracts/staking-router.ts index 9b6a7b9..e4a49fe 100644 --- a/apps/web/src/lib/contracts/staking-router.ts +++ b/apps/web/src/lib/contracts/staking-router.ts @@ -1,4 +1,4 @@ -import { Contract, TransactionBuilder, rpc, xdr } from "@stellar/stellar-sdk" +import { Contract, TransactionBuilder, rpc, xdr, scValToNative, Address, Account } from "@stellar/stellar-sdk" import type { Transaction } from "@stellar/stellar-sdk" import { CONTRACTS } from "@/app/config/contracts" import { NETWORK } from "@/app/config/network" @@ -43,7 +43,44 @@ export class StakingRouterClient implements StakingRouterBinding { return this.invoke("claimRewards", [xdr.ScVal.scvString(account)]) } - async getStakerInfo(_account: string): Promise { + async getStakerInfo(account: string): Promise { + const contract = new Contract(this.contractId) + const dummyAccount = new Account("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", "0") + const accountVal = new Address(account).toScVal() + + const tx = new TransactionBuilder(dummyAccount, { + fee: "100", + networkPassphrase: NETWORK.networkPassphrase, + }) + .addOperation(contract.call("getStakerInfo", accountVal)) + .setTimeout(30) + .build() + + try { + const simulation = await sorobanRpc.simulateTransaction(tx) + if (rpc.Api.isSimulationSuccess(simulation)) { + const retval = simulation.result?.retval + if (retval) { + const native = scValToNative(retval) + if (native && typeof native === "object") { + const n = native as Record + return { + stakedSO4: BigInt(n.stakedSO4?.toString() ?? n.stakedAmount?.toString() ?? 0), + stakedEsSO4: BigInt(n.stakedEsSO4?.toString() ?? n.esSO4Balance?.toString() ?? 0), + stakedMultiplierPoints: BigInt(n.stakedMultiplierPoints?.toString() ?? 0), + pendingEsSO4Rewards: BigInt(n.pendingEsSO4Rewards?.toString() ?? n.accruedRewards?.toString() ?? 0), + pendingWethFees: BigInt(n.pendingWethFees?.toString() ?? 0), + esSO4Balance: BigInt(n.esSO4Balance?.toString() ?? 0), + stakedAmount: BigInt(n.stakedAmount?.toString() ?? 0), + accruedRewards: BigInt(n.accruedRewards?.toString() ?? 0), + } + } + } + } + } catch { + // fall through to default + } + return { stakedSO4: 0n, stakedEsSO4: 0n, @@ -123,3 +160,28 @@ export function buildUnstakeSO4Transaction(account: string, amount: bigint): Pro export function buildClaimRewardsTransaction(account: string): Promise { return buildClaimRewardsTx(account) } + +async function buildCompoundTx(account: string): Promise { + const sourceAccount = await sorobanRpc.getAccount(account) + const contract = new Contract(CONTRACTS.stakingRouter) + + const tx = new TransactionBuilder(sourceAccount, { + fee: "100", + networkPassphrase: NETWORK.networkPassphrase, + }) + .addOperation(contract.call("compound", xdr.ScVal.scvString(account))) + .setTimeout(180) + .build() + + const simulation = await sorobanRpc.simulateTransaction(tx) + if (rpc.Api.isSimulationError(simulation)) { + throw new Error(`Transaction simulation failed: ${simulation.error}`) + } + + return rpc.assembleTransaction(tx, simulation).build() +} + +/** Build a fee-assembled Soroban transaction calling StakingRouter.compound. */ +export function buildCompoundTransaction(account: string): Promise { + return buildCompoundTx(account) +} diff --git a/apps/web/src/lib/contracts/synthetics-reader.ts b/apps/web/src/lib/contracts/synthetics-reader.ts index e22b558..9377266 100644 --- a/apps/web/src/lib/contracts/synthetics-reader.ts +++ b/apps/web/src/lib/contracts/synthetics-reader.ts @@ -1,8 +1,8 @@ import { CONTRACTS } from "@/app/config/contracts" import { NETWORK } from "@/app/config/network" -import { Client, type MarketInfo, type OrderInfo, type PositionInfo } from "./generated/synthetics-reader/src" +import { Client, type MarketInfo, type OrderInfo, type PositionInfo, type PoolAmounts } from "./generated/synthetics-reader/src" -export type { MarketInfo, OrderInfo, PositionInfo } +export type { MarketInfo, OrderInfo, PositionInfo, PoolAmounts } /** * Thin wrapper around the generated SyntheticsReader Soroban client. @@ -31,5 +31,9 @@ export class SyntheticsReaderClient { getPositionInfo(account: string, marketAddress: string, isLong: boolean): Promise { return this.client.getPositionInfo(account, marketAddress, isLong) } + + getMarketPoolAmounts(marketAddress: string): Promise { + return this.client.getMarketPoolAmounts(marketAddress) + } } diff --git a/apps/web/src/lib/contracts/vesting-router.ts b/apps/web/src/lib/contracts/vesting-router.ts index 01cb0bc..249750a 100644 --- a/apps/web/src/lib/contracts/vesting-router.ts +++ b/apps/web/src/lib/contracts/vesting-router.ts @@ -1,4 +1,8 @@ +import { Contract, TransactionBuilder, rpc, Address, nativeToScVal, Account, scValToNative } from "@stellar/stellar-sdk" +import type { Transaction } from "@stellar/stellar-sdk" import { CONTRACTS } from "@/app/config/contracts" +import { NETWORK } from "@/app/config/network" +import { sorobanRpc } from "@/lib/soroban/client" export type VestingSchedule = { /** Total esSO4 locked into vesting. */ @@ -15,12 +19,10 @@ export type VestingRouterBinding = { getVestingSchedule: (account: string) => Promise } +const DUMMY_ACCOUNT = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" + /** * Client for the VestingRouter Soroban contract (esSO4 12-month linear vesting). - * - * The read returns the empty default until the shared Soroban read layer is - * implemented (see StakingRouterClient); the signature/return type are the - * stable surface that useVestingSchedule consumes. */ export class VestingRouterClient implements VestingRouterBinding { readonly contractId: string @@ -29,9 +31,65 @@ export class VestingRouterClient implements VestingRouterBinding { this.contractId = contractId } - async getVestingSchedule(_account: string): Promise { - // TODO: simulate VestingRouter.get_vesting_schedule(account) on - // `this.contractId` and decode the i128 deposited/vested/claimable + u64 end. + async getVestingSchedule(account: string): Promise { + const contract = new Contract(this.contractId) + const dummyAccount = new Account(DUMMY_ACCOUNT, "0") + const accountVal = new Address(account).toScVal() + + const tx = new TransactionBuilder(dummyAccount, { + fee: "100", + networkPassphrase: NETWORK.networkPassphrase, + }) + .addOperation(contract.call("get_vesting_schedule", accountVal)) + .setTimeout(30) + .build() + + try { + const simulation = await sorobanRpc.simulateTransaction(tx) + if (rpc.Api.isSimulationSuccess(simulation)) { + const retval = simulation.result?.retval + if (retval) { + const native = scValToNative(retval) + if (native && typeof native === "object") { + return { + deposited: BigInt(native.locked ?? native.deposited ?? 0n), + vested: BigInt(native.unlocked ?? native.vested ?? 0n), + claimable: BigInt(native.claimable ?? 0n), + vestingEndTimestamp: Number(native.end ?? native.vestingEndTimestamp ?? 0), + } + } + } + } + } catch { + // fall through to default + } + return { deposited: 0n, vested: 0n, claimable: 0n, vestingEndTimestamp: 0 } } } + +/** + * Build a fee-assembled Soroban transaction calling VestingRouter.deposit_for_vesting. + */ +export async function buildDepositForVestingTransaction( + account: string, + amount: bigint, +): Promise { + const sourceAccount = await sorobanRpc.getAccount(account) + const contract = new Contract(CONTRACTS.vestingRouter) + + const tx = new TransactionBuilder(sourceAccount, { + fee: "100", + networkPassphrase: NETWORK.networkPassphrase, + }) + .addOperation(contract.call("deposit_for_vesting", new Address(account).toScVal(), nativeToScVal(amount, { type: "i128" }))) + .setTimeout(180) + .build() + + const simulation = await sorobanRpc.simulateTransaction(tx) + if (rpc.Api.isSimulationError(simulation)) { + throw new Error(`Transaction simulation failed: ${simulation.error}`) + } + + return rpc.assembleTransaction(tx, simulation).build() +}