From 5e349262e4356d1bef330aee632311101827bcab Mon Sep 17 00:00:00 2001 From: Santiago Villarreal Date: Sun, 8 Mar 2026 22:57:00 -0500 Subject: [PATCH] feat: added interest-auctions for backstop. --- .../src/app/(app)/dashboard/backstop/page.tsx | 12 + .../dashboard/components/ui/QuickActions.tsx | 14 +- .../components/InterestAuctionSection.tsx | 409 ++++++++++++++++++ .../lending/components/pages/Backstop.tsx | 25 ++ .../lending/hooks/useInterestAuction.ts | 230 ++++++++++ .../constants/generated/contract-errors.ts | 7 +- .../src/lib/helpers/stellar/lending.ts | 203 +++++++++ .../src/lib/services/lending.service.ts | 73 ++++ 8 files changed, 967 insertions(+), 6 deletions(-) create mode 100644 apps/web-app/src/app/(app)/dashboard/backstop/page.tsx create mode 100644 apps/web-app/src/features/lending/components/InterestAuctionSection.tsx create mode 100644 apps/web-app/src/features/lending/components/pages/Backstop.tsx create mode 100644 apps/web-app/src/features/lending/hooks/useInterestAuction.ts diff --git a/apps/web-app/src/app/(app)/dashboard/backstop/page.tsx b/apps/web-app/src/app/(app)/dashboard/backstop/page.tsx new file mode 100644 index 0000000..00f0b1c --- /dev/null +++ b/apps/web-app/src/app/(app)/dashboard/backstop/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import Backstop from "@/features/lending/components/pages/Backstop"; + +export const metadata: Metadata = { + title: "Backstop | Neko Protocol", + description: + "Create and participate in interest auctions. Accumulated protocol interest is distributed to backstop depositors.", +}; + +export default function BackstopPage() { + return ; +} diff --git a/apps/web-app/src/features/dashboard/components/ui/QuickActions.tsx b/apps/web-app/src/features/dashboard/components/ui/QuickActions.tsx index dd4a1f3..b885167 100644 --- a/apps/web-app/src/features/dashboard/components/ui/QuickActions.tsx +++ b/apps/web-app/src/features/dashboard/components/ui/QuickActions.tsx @@ -2,7 +2,13 @@ import React, { useState } from "react"; import Link from "next/link"; -import { ArrowLeftRight, Droplets, Compass, ArrowRight } from "lucide-react"; +import { + ArrowLeftRight, + Droplets, + Compass, + Gavel, + ArrowRight, +} from "lucide-react"; import { useWallet } from "@/hooks/useWallet"; import { ConnectWalletModal } from "@/features/wallet/components/ConnectWalletModal"; @@ -25,6 +31,12 @@ const actions = [ href: "/discover", icon: Compass, }, + { + label: "Backstop", + description: "Interest auctions", + href: "/dashboard/backstop", + icon: Gavel, + }, ] as const; const QuickActions: React.FC = () => { diff --git a/apps/web-app/src/features/lending/components/InterestAuctionSection.tsx b/apps/web-app/src/features/lending/components/InterestAuctionSection.tsx new file mode 100644 index 0000000..06ed97e --- /dev/null +++ b/apps/web-app/src/features/lending/components/InterestAuctionSection.tsx @@ -0,0 +1,409 @@ +"use client"; + +import React, { useState } from "react"; +import Link from "next/link"; +import { + Gavel, + HandCoins, + Loader2, + Info, + ChevronDown, + ChevronRight, + ArrowRight, +} from "lucide-react"; +import { useInterestAuction } from "../hooks/useInterestAuction"; +import { ConnectWalletModal } from "@/features/wallet/components/ConnectWalletModal"; + +const MIN_AUCTION_AMOUNT = 100; + +function HowItWorks() { + const [expanded, setExpanded] = useState(false); + return ( +
+ + {expanded && ( +
+
    +
  1. + + 1 + + + Interest accumulates{" "} + when users borrow from lending pools. A portion goes to the + backstop. + +
  2. +
  3. + + 2 + + + Create an auction when + there is at least {MIN_AUCTION_AMOUNT} units accumulated. Anyone + can trigger it. + +
  4. +
  5. + + 3 + + + Participate by paying + backstop tokens to receive the interest. You need backstop + tokens (from depositing in the backstop). + +
  6. +
+
+ )} +
+ ); +} + +export function InterestAuctionSection() { + const [showWalletModal, setShowWalletModal] = useState(false); + const [participateAuctionId, setParticipateAuctionId] = useState(""); + const [participateAsset, setParticipateAsset] = useState(""); + const [participateContractId, setParticipateContractId] = useState(""); + const [participateFillPercent, setParticipateFillPercent] = useState("100"); + const [isCreating, setIsCreating] = useState(null); + const [isFilling, setIsFilling] = useState(false); + + const { + assets, + isLoadingAssets, + hasWallet, + createAuction, + fillAuction, + } = useInterestAuction(); + + const handleCreateAuction = async (assetCode: string, contractId: string) => { + if (!hasWallet) { + setShowWalletModal(true); + return; + } + setIsCreating(assetCode); + try { + await createAuction(assetCode, contractId); + } finally { + setIsCreating(null); + } + }; + + const handleFillAuction = async () => { + if (!hasWallet) { + setShowWalletModal(true); + return; + } + const auctionId = parseInt(participateAuctionId, 10); + const fillPercent = parseFloat(participateFillPercent); + if ( + !Number.isFinite(auctionId) || + auctionId < 0 || + !participateAsset || + !participateContractId + ) { + return; + } + setIsFilling(true); + try { + await fillAuction({ + auctionId, + assetCode: participateAsset, + contractId: participateContractId, + fillPercent, + }); + setParticipateAuctionId(""); + setParticipateAsset(""); + setParticipateContractId(""); + setParticipateFillPercent("100"); + } finally { + setIsFilling(false); + } + }; + + const canFill = + hasWallet && + participateAuctionId.trim() !== "" && + participateAsset !== "" && + participateContractId !== "" && + participateFillPercent !== "" && + parseFloat(participateFillPercent) >= 1 && + parseFloat(participateFillPercent) <= 100 && + Number.isFinite(parseInt(participateAuctionId, 10)); + + const hasAnyReady = assets.some((a) => a.canCreate); + + return ( +
+ {/* How it works */} + + + {/* Create Auction */} +
+
+
+ +
+
+

+ Create Interest Auction +

+

+ Trigger an auction when enough interest has accumulated +

+
+
+ + {!hasWallet ? ( +
+

+ Connect your wallet to create or participate in auctions +

+ +
+ ) : isLoadingAssets ? ( +
+ + Loading assets... +
+ ) : assets.length === 0 ? ( +
+ No lending pools available +
+ ) : ( + <> + {!hasAnyReady && hasWallet && assets.length > 0 && ( +
+
+ +
+

+ No auctions available yet +

+

+ You need at least {MIN_AUCTION_AMOUNT} units of accumulated + interest per asset. Interest accrues when users borrow from + the pool. +

+
+ + Deposit to pool + + + · + + Borrow (generates interest) + + +
+
+
+
+ )} +
+ {assets.map((asset) => ( +
+
+
+ + {asset.assetCode} + + {asset.canCreate ? ( + + Ready to create + + ) : ( + + Need {MIN_AUCTION_AMOUNT}+ {asset.assetCode} + + )} +
+

+ Accumulated: {asset.accumulatedInterestFormatted}{" "} + {asset.assetCode} +

+
+ +
+ ))} +
+ + )} +
+ + {/* Participate in Auction */} +
+
+
+ +
+
+

+ Participate in Auction +

+

+ Pay backstop tokens to receive accumulated interest +

+
+
+ + {!hasWallet ? ( +
+

+ Connect your wallet to participate +

+
+ ) : ( +
+

+ Enter the auction details below. You need backstop tokens to + participate. +

+
+ + setParticipateAuctionId(e.target.value)} + placeholder="e.g. 1" + className="w-full rounded-lg bg-[#222222] border border-white/10 px-3 py-2 text-white text-sm placeholder:text-white/30 focus:outline-none focus:border-[#229EDF]" + /> +

+ Shown when you create an auction, or from transaction events +

+
+
+ + +

+ The asset being auctioned (interest you will receive) +

+
+
+ + setParticipateFillPercent(e.target.value)} + className="w-full rounded-lg bg-[#222222] border border-white/10 px-3 py-2 text-white text-sm placeholder:text-white/30 focus:outline-none focus:border-[#229EDF]" + /> +

+ Percentage of the auction to fill (100 = full auction) +

+
+ {!canFill && ( +

+ Fill in Auction ID and select an asset to enable participation +

+ )} + +
+ )} +
+ + setShowWalletModal(false)} + /> +
+ ); +} diff --git a/apps/web-app/src/features/lending/components/pages/Backstop.tsx b/apps/web-app/src/features/lending/components/pages/Backstop.tsx new file mode 100644 index 0000000..2cf4768 --- /dev/null +++ b/apps/web-app/src/features/lending/components/pages/Backstop.tsx @@ -0,0 +1,25 @@ +"use client"; + +import React from "react"; +import { BannerPage } from "@/components/ui/BannerPage"; +import { PageContainer } from "@/components/ui/PageContainer"; +import { InterestAuctionSection } from "../InterestAuctionSection"; + +const Backstop: React.FC = () => { + return ( +
+ + + + +
+ ); +}; + +export default Backstop; diff --git a/apps/web-app/src/features/lending/hooks/useInterestAuction.ts b/apps/web-app/src/features/lending/hooks/useInterestAuction.ts new file mode 100644 index 0000000..3c5008d --- /dev/null +++ b/apps/web-app/src/features/lending/hooks/useInterestAuction.ts @@ -0,0 +1,230 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { Networks } from "@stellar/stellar-sdk"; +import { useWallet } from "@/hooks/useWallet"; +import { useToast } from "@/hooks/useToast"; +import { + canCreateInterestAuction, + getAccumulatedInterest, + createInterestAuctionXdr, + fillInterestAuctionXdr, + type FillInterestAuctionParams, +} from "@/lib/helpers/stellar/lending"; +import { + signAndSendTransaction, + type SignTransactionFn, +} from "@/lib/helpers/stellar/transaction"; +import { rpcUrl } from "@/lib/constants/network"; +import { extractContractErrorOrNull } from "@/lib/helpers/stellar/contractErrors"; +import { TOAST_CONFIG } from "@/lib/constants/toast.config"; +import { fromSmallestUnit } from "@/lib/helpers/tokenUtils"; +import { useLendingPools } from "./useLendingPools"; +import { networks } from "@neko/lending"; + +export interface InterestAuctionAsset { + assetCode: string; + contractId: string; + canCreate: boolean; + accumulatedInterest: bigint; + accumulatedInterestFormatted: string; + decimals: number; +} + +export const INTEREST_AUCTION_QUERY_KEY = "interestAuction"; + +export function useInterestAuction() { + const queryClient = useQueryClient(); + const { addNotification } = useToast(); + const { address, signTransaction, networkPassphrase } = useWallet(); + const { data: lendingPools = [], refetch: refetchPools } = useLendingPools(); + + const showError = useCallback( + (msg: string) => + addNotification("Something went wrong", "error", { + ...TOAST_CONFIG.defaultOpts, + description: msg, + }), + [addNotification] + ); + + const showSuccess = useCallback( + (msg: string) => + addNotification("Success", "success", { + ...TOAST_CONFIG.defaultOpts, + description: msg, + }), + [addNotification] + ); + + const assetQueries = useQueries({ + queries: lendingPools.map((pool) => ({ + queryKey: [INTEREST_AUCTION_QUERY_KEY, pool.assetCode, pool.contractId], + queryFn: async (): Promise => { + const [canCreate, accumulatedInterest] = await Promise.all([ + canCreateInterestAuction(pool.assetCode, pool.contractId), + getAccumulatedInterest(pool.assetCode, pool.contractId), + ]); + + const decimals = 7; + const accumulatedInterestFormatted = + accumulatedInterest > 0n + ? fromSmallestUnit(accumulatedInterest.toString(), decimals) + : "0"; + + return { + assetCode: pool.assetCode, + contractId: pool.contractId, + canCreate, + accumulatedInterest, + accumulatedInterestFormatted, + decimals, + }; + }, + staleTime: 60_000, + enabled: !!pool.assetCode && !!pool.contractId, + })), + }); + + const assets: InterestAuctionAsset[] = useMemo( + () => + assetQueries + .filter((q) => q.data) + .map((q) => q.data!) as InterestAuctionAsset[], + [assetQueries] + ); + + const isLoadingAssets = assetQueries.some((q) => q.isLoading); + const invalidateQueries = useCallback(() => { + queryClient.invalidateQueries({ queryKey: [INTEREST_AUCTION_QUERY_KEY] }); + refetchPools(); + }, [queryClient, refetchPools]); + + const createAuction = useCallback( + async (assetCode: string, contractId: string) => { + if (!address) { + showError("Please connect your wallet first"); + return { success: false as const, auctionId: undefined }; + } + + try { + const xdr = await createInterestAuctionXdr( + assetCode, + address, + contractId + ); + + await signAndSendTransaction( + xdr, + signTransaction as SignTransactionFn, + { + networkPassphrase: networkPassphrase || Networks.TESTNET, + rpcUrl, + address, + waitForPending: true, + } + ); + + showSuccess(`Interest auction created for ${assetCode}`); + invalidateQueries(); + return { success: true as const, auctionId: undefined }; + } catch (err) { + const friendlyError = extractContractErrorOrNull(err); + showError( + typeof friendlyError === "string" + ? friendlyError + : "Failed to create interest auction. Please try again." + ); + return { success: false as const, auctionId: undefined }; + } + }, + [ + address, + networkPassphrase, + signTransaction, + showError, + showSuccess, + invalidateQueries, + ] + ); + + const fillAuction = useCallback( + async (params: { + auctionId: number; + assetCode: string; + contractId: string; + fillPercent: number; + }) => { + if (!address) { + showError("Please connect your wallet first"); + return { success: false as const }; + } + + if ( + params.fillPercent <= 0 || + params.fillPercent > 100 || + !Number.isFinite(params.fillPercent) + ) { + showError("Fill percentage must be between 1 and 100"); + return { success: false as const }; + } + + try { + const fillParams: FillInterestAuctionParams = { + auctionId: params.auctionId, + bidder: address, + asset: params.assetCode, + fillPercent: params.fillPercent, + }; + + const xdr = await fillInterestAuctionXdr(fillParams, params.contractId); + + await signAndSendTransaction( + xdr, + signTransaction as SignTransactionFn, + { + networkPassphrase: networkPassphrase || Networks.TESTNET, + rpcUrl, + address, + waitForPending: true, + } + ); + + showSuccess( + `Successfully participated in auction #${params.auctionId} for ${params.assetCode}` + ); + invalidateQueries(); + return { success: true as const }; + } catch (err) { + const friendlyError = extractContractErrorOrNull(err); + showError( + typeof friendlyError === "string" + ? friendlyError + : "Failed to participate in auction. Please try again." + ); + return { success: false as const }; + } + }, + [ + address, + networkPassphrase, + signTransaction, + showError, + showSuccess, + invalidateQueries, + ] + ); + + return { + assets, + isLoadingAssets, + hasWallet: !!address, + createAuction, + fillAuction, + poolContractIds: { + pool1: networks.testnet.pool1ContractId, + pool2: networks.testnet.pool2ContractId, + }, + }; +} diff --git a/apps/web-app/src/lib/constants/generated/contract-errors.ts b/apps/web-app/src/lib/constants/generated/contract-errors.ts index d331497..d9aac92 100644 --- a/apps/web-app/src/lib/constants/generated/contract-errors.ts +++ b/apps/web-app/src/lib/constants/generated/contract-errors.ts @@ -4,7 +4,7 @@ * This file is automatically generated from Stellar smart contract error definitions. * To regenerate, run: npm run generate:errors * - * Generated at: 2026-03-08T14:16:50.215Z + * Generated at: 2026-03-09T03:39:57.817Z * * Source files: * - apps/contracts/stellar-contracts/rwa-lending/src/common/error.rs @@ -801,10 +801,7 @@ export function isValidErrorCode(code: number): code is ContractErrorCode { * Errors grouped by contract name, then by error code. * Use this for contract-specific lookups when multiple contracts share the same code. */ -export const CONTRACT_ERRORS_BY_CONTRACT: Record< - string, - Record -> = (() => { +export const CONTRACT_ERRORS_BY_CONTRACT: Record> = (() => { const byContract: Record> = {}; for (const [code, info] of Object.entries(CONTRACT_ERRORS)) { const n = Number(code); diff --git a/apps/web-app/src/lib/helpers/stellar/lending.ts b/apps/web-app/src/lib/helpers/stellar/lending.ts index 5d2ca61..5090c9e 100644 --- a/apps/web-app/src/lib/helpers/stellar/lending.ts +++ b/apps/web-app/src/lib/helpers/stellar/lending.ts @@ -475,3 +475,206 @@ export const getBTokenBalance = async ( return "0"; } }; + +// ========== Interest Auction Helpers ========== + +/** SCALAR_7 = 10^7 for 7-decimal percentage (100% = 100_000000) */ +const SCALAR_7 = 100_000_000n; + +/** + * Check if an interest auction can be created for an asset + */ +export const canCreateInterestAuction = async ( + asset: string, + contractId: string = networks.testnet.contractId +): Promise => { + try { + const client = new RwaLendingClient({ + contractId, + rpcUrl: rpcUrl, + networkPassphrase: networkPassphrase, + ...(allowHttpForSoroban && { allowHttp: true }), + }); + + const tx = await client.can_create_interest_auction( + { asset }, + { simulate: true } + ); + + return tx.result ?? false; + } catch (error) { + console.error("Error checking can_create_interest_auction:", error); + return false; + } +}; + +/** + * Get accumulated backstop_credit (interest) for an asset + */ +export const getAccumulatedInterest = async ( + asset: string, + contractId: string = networks.testnet.contractId +): Promise => { + try { + const client = new RwaLendingClient({ + contractId, + rpcUrl: rpcUrl, + networkPassphrase: networkPassphrase, + ...(allowHttpForSoroban && { allowHttp: true }), + }); + + const tx = await client.get_accumulated_interest( + { asset }, + { simulate: true } + ); + + const value = tx.result; + if (!value) return 0n; + + return typeof value === "bigint" ? value : BigInt(String(value)); + } catch (error) { + console.error("Error getting accumulated interest:", error); + return 0n; + } +}; + +/** + * Build create_interest_auction transaction XDR for signing + */ +export const createInterestAuctionXdr = async ( + asset: string, + walletAddress: string, + contractId: string = networks.testnet.contractId +): Promise => { + try { + const sorobanServer = new rpc.Server(rpcUrl, { + allowHttp: stellarNetwork === "LOCAL", + }); + const horizonServer = new Horizon.Server(horizonUrl); + const lendingContract = new Contract(contractId); + + const assetSymbol = xdr.ScVal.scvSymbol(asset); + + const operation = lendingContract.call( + "create_interest_auction", + assetSymbol + ); + + const account = await horizonServer.loadAccount(walletAddress); + + const transaction = new TransactionBuilder(account, { + fee: "100", + networkPassphrase: networkPassphrase, + }) + .addOperation(operation) + .setTimeout(300) + .build(); + + try { + await sorobanServer.simulateTransaction(transaction); + } catch (simError) { + const errorMessage = + simError instanceof Error ? simError.message : String(simError); + if ( + !errorMessage.includes("Auth") && + !errorMessage.includes("require_auth") && + !errorMessage.includes("InvalidAction") + ) { + const friendlyError = extractContractError(simError, "rwa-lending"); + throw new Error(friendlyError); + } + } + + const preparedTx = await sorobanServer.prepareTransaction(transaction); + + return preparedTx.toXDR(); + } catch (error) { + console.error("Error building create_interest_auction transaction:", error); + if ( + error instanceof Error && + error.message && + !error.message.includes("Failed to build") + ) { + throw error; + } + const friendlyError = extractContractError(error, "rwa-lending"); + throw new Error(friendlyError); + } +}; + +export interface FillInterestAuctionParams { + auctionId: number; + bidder: string; + asset: string; + /** Human-readable percentage 1-100 (e.g. 50 = 50%) */ + fillPercent: number; +} + +/** + * Build fill_interest_auction transaction XDR for signing + * fillPercent is converted to 7 decimals (e.g. 50 -> 50_000000) + */ +export const fillInterestAuctionXdr = async ( + params: FillInterestAuctionParams, + contractId: string = networks.testnet.contractId +): Promise => { + try { + const sorobanServer = new rpc.Server(rpcUrl, { + allowHttp: stellarNetwork === "LOCAL", + }); + const horizonServer = new Horizon.Server(horizonUrl); + const lendingContract = new Contract(contractId); + + const fillPercentScaled = BigInt( + Math.floor(params.fillPercent * Number(SCALAR_7) / 100) + ); + + const operation = lendingContract.call( + "fill_interest_auction", + nativeToScVal(params.auctionId, { type: "u32" }), + new Address(params.bidder).toScVal(), + xdr.ScVal.scvSymbol(params.asset), + nativeToScVal(fillPercentScaled, { type: "i128" }) + ); + + const account = await horizonServer.loadAccount(params.bidder); + + const transaction = new TransactionBuilder(account, { + fee: "100", + networkPassphrase: networkPassphrase, + }) + .addOperation(operation) + .setTimeout(300) + .build(); + + try { + await sorobanServer.simulateTransaction(transaction); + } catch (simError) { + const errorMessage = + simError instanceof Error ? simError.message : String(simError); + if ( + !errorMessage.includes("Auth") && + !errorMessage.includes("require_auth") && + !errorMessage.includes("InvalidAction") + ) { + const friendlyError = extractContractError(simError, "rwa-lending"); + throw new Error(friendlyError); + } + } + + const preparedTx = await sorobanServer.prepareTransaction(transaction); + + return preparedTx.toXDR(); + } catch (error) { + console.error("Error building fill_interest_auction transaction:", error); + if ( + error instanceof Error && + error.message && + !error.message.includes("Failed to build") + ) { + throw error; + } + const friendlyError = extractContractError(error, "rwa-lending"); + throw new Error(friendlyError); + } +}; diff --git a/apps/web-app/src/lib/services/lending.service.ts b/apps/web-app/src/lib/services/lending.service.ts index 24d6f5e..b882a93 100644 --- a/apps/web-app/src/lib/services/lending.service.ts +++ b/apps/web-app/src/lib/services/lending.service.ts @@ -28,6 +28,11 @@ import { approveToken, addCollateral, borrowFromPool, + canCreateInterestAuction as canCreateInterestAuctionHelper, + getAccumulatedInterest as getAccumulatedInterestHelper, + createInterestAuctionXdr as buildCreateInterestAuctionXdr, + fillInterestAuctionXdr as buildFillInterestAuctionXdr, + type FillInterestAuctionParams, } from "../helpers/stellar/lending"; import { extractContractError } from "../helpers/stellar/contractErrors"; @@ -736,6 +741,74 @@ export class LendingService { } } + /** + * Check if an interest auction can be created for an asset + */ + async canCreateInterestAuction( + asset: string, + contractId?: string + ): Promise { + return canCreateInterestAuctionHelper( + asset, + contractId ?? this.lendingClient.options.contractId + ); + } + + /** + * Get accumulated backstop_credit (interest) for an asset + */ + async getAccumulatedInterest( + asset: string, + contractId?: string + ): Promise { + return getAccumulatedInterestHelper( + asset, + contractId ?? this.lendingClient.options.contractId + ); + } + + /** + * Build create_interest_auction transaction XDR for signing + */ + async createInterestAuctionXdr( + asset: string, + walletAddress: string, + contractId?: string + ): Promise { + try { + const xdr = await buildCreateInterestAuctionXdr( + asset, + walletAddress, + contractId ?? this.lendingClient.options.contractId + ); + return { xdr }; + } catch (error) { + console.error("Error building create_interest_auction:", error); + const friendlyError = extractContractError(error, "rwa-lending"); + return { xdr: "", error: friendlyError }; + } + } + + /** + * Build fill_interest_auction transaction XDR for signing + */ + async fillInterestAuctionXdr( + params: FillInterestAuctionParams, + contractId?: string + ): Promise { + try { + const xdr = await buildFillInterestAuctionXdr( + params, + contractId ?? this.lendingClient.options.contractId + ); + return { xdr }; + } catch (error) { + console.error("Error building fill_interest_auction:", error); + const friendlyError = extractContractError(error, "rwa-lending"); + return { xdr: "", error: friendlyError }; + } + } + /** * Get collateral balance for a user and RWA token */