diff --git a/app/markets/quick-view/page.tsx b/app/markets/quick-view/page.tsx index 535cf76..c7d2a2a 100644 --- a/app/markets/quick-view/page.tsx +++ b/app/markets/quick-view/page.tsx @@ -13,11 +13,13 @@ import { useQuery } from "@tanstack/react-query"; import { useMarket } from "@/lib/hooks/useMarkets"; import { getMarketByOnChainId } from "@/lib/api/markets"; import { ApiError } from "@/lib/api/client"; -import { useBuy, useOnChainMarket, usePosition, useQuoteBuy, useUsdcBalance, useAccountReserve, usePrepareBuy, useMarketConfig, useLedgerNow, useEnableUsdc, useUsdcTrustline, useExactBuyQuote, usePositionState, useUsdcBalanceState } from "@/lib/hooks/useStellar"; +import { useBuy, useOnChainMarket, usePosition, useQuoteBuy, useUsdcBalance, useAccountReserve, usePrepareBuy, useMarketConfig, useLedgerNow, useEnableUsdc, useUsdcTrustline, useExactBuyQuote, usePositionState, useUsdcBalanceState, useAccountAuth, usePrepareMultisig } from "@/lib/hooks/useStellar"; import { useWallet } from "@/lib/contexts/WalletContext"; import { explorerTx } from "@/lib/stellar/config"; import { formatUsdc, formatXlm, maxIn, minOut, tryParseUsdc } from "@/lib/stellar/units"; import { buyBreakdown } from "@/lib/stellar/buy-breakdown"; +import { isMultisigAccount, MULTISIG_DEFAULT_SLIPPAGE_BPS, type MultisigSession } from "@/lib/stellar/multisig"; +import { MultisigReview } from "@/components/multisig-review"; import type { TxPhase } from "@/lib/stellar/tx-lifecycle"; import { preflightBuy } from "@/lib/stellar/preflight"; @@ -114,6 +116,12 @@ function MarketQuickViewContent() { const trustlineUnauthorized = trustline.data?.kind === "present" && !trustline.data.authorized; const buy = useBuy(); const submitQueue = useSubmitQueueState(address); + const accountAuth = useAccountAuth(address); + const isMultisig = accountAuth.data ? isMultisigAccount(accountAuth.data) : false; + const [multisigMode, setMultisigMode] = useState(false); + const [multisigSession, setMultisigSession] = useState(null); + const [multisigStatus, setMultisigStatus] = useState(null); + const prepareMultisig = usePrepareMultisig(); const [now, setNow] = useState(null); useEffect(() => { @@ -255,6 +263,41 @@ function MarketQuickViewContent() { } }; + const handlePrepareMultisig = async () => { + if (!isConnected) { + void openModal(); + return; + } + if (!onChainMarketId) return; + if (isWrongNetwork) { + setMultisigStatus("Switch your wallet to Stellar Testnet to prepare this trade."); + return; + } + if (!spendRaw || spendRaw <= 0n) { + setMultisigStatus(buyMode === "usdc" ? "Enter an amount like 10 or 2.5." : "Enter a share amount greater than zero."); + return; + } + if (buyMode === "shares") { + setMultisigStatus("Multisig prepare currently supports USDC amounts. Switch to USDC and try again."); + return; + } + setMultisigStatus(null); + try { + const session = await prepareMultisig.mutateAsync({ + kind: "buy", + marketId: onChainMarketId, + outcome: side, + collateralIn: spendRaw, + slippageBps: MULTISIG_DEFAULT_SLIPPAGE_BPS, + sourceAccount: address ?? undefined, + timeoutInSeconds: 3600, + }); + setMultisigSession(session); + } catch (err) { + setMultisigStatus(err instanceof Error ? err.message : "Couldn't prepare the multisig transaction."); + } + }; + if (isLoading) { return (
@@ -544,6 +587,42 @@ function MarketQuickViewContent() { {!buy.isPending && buyStatus && (

{buyStatus}

)} + + {/* Multisig path */} + {isConnected && ( +
+ + {accountAuth.isLoading &&

Checking multisig thresholds…

} + {isMultisig && !multisigMode && ( +

+ This account needs multiple signatures (medium threshold {accountAuth.data?.med}, {accountAuth.data?.signers.length} signers). For treasuries, use “Prepare for multisig”. +

+ )} + {(multisigMode || isMultisig) && ( +
+ +

+ Builds with a 1-hour window and 3% slippage. Quote is from the current ledger; the trade fails with SlippageExceeded (error 12) if the price moves further. +

+ {multisigStatus &&

{multisigStatus}

} +
+ )} +
+ )} )} @@ -594,6 +673,13 @@ function MarketQuickViewContent() { onCancel={() => closeReview(false)} /> )} + {multisigSession && ( + setMultisigSession(null)} + onUpdated={(next) => setMultisigSession(next)} + /> + )}
); } diff --git a/app/premium/page.tsx b/app/premium/page.tsx index 9cc5c0a..b84509d 100644 --- a/app/premium/page.tsx +++ b/app/premium/page.tsx @@ -9,11 +9,13 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { useWallet } from "@/lib/contexts/WalletContext"; -import { useUsdcTransfer, useAccountReserve, usePrepareUsdcTransfer } from "@/lib/hooks/useStellar"; +import { useUsdcTransfer, useAccountReserve, usePrepareUsdcTransfer, useAccountAuth, usePrepareMultisig } from "@/lib/hooks/useStellar"; import { useUnlockTrace } from "@/lib/hooks/useTraces"; import { PAYMENTS_RECIPIENT, explorerTx } from "@/lib/stellar/config"; import { formatUsdc, formatXlm, parseUsdc } from "@/lib/stellar/units"; import { TxPhase } from "@/lib/stellar/tx-lifecycle"; +import { isMultisigAccount, type MultisigSession } from "@/lib/stellar/multisig"; +import { MultisigReview } from "@/components/multisig-review"; function renderTxPhase(phase: TxPhase): string | null { switch (phase.name) { @@ -124,6 +126,11 @@ export default function PremiumPage() { const [statusMessage, setStatusMessage] = useState(null); const [reviewModel, setReviewModel] = useState<{ model: ReviewModel; changedFrom?: ReviewModel } | null>(null); const reviewResolver = useRef<((approved: boolean) => void) | null>(null); + const accountAuth = useAccountAuth(address); + const isMultisig = accountAuth.data ? isMultisigAccount(accountAuth.data) : false; + const [multisigMode, setMultisigMode] = useState(false); + const [multisigSession, setMultisigSession] = useState(null); + const prepareMultisig = usePrepareMultisig(); const waitingForTransfer = submitQueue.waiting.some((job) => job.label === "USDC transfer") || (submitQueue.lockWaiting && submitQueue.running?.label === "USDC transfer"); @@ -176,6 +183,22 @@ export default function PremiumPage() { setSelectedTier(tier); }; + const handlePrepareMultisigTransfer = async () => { + if (!selectedTier?.priceRaw || !address) return; + try { + const session = await prepareMultisig.mutateAsync({ + kind: "transfer", + to: PAYMENTS_RECIPIENT, + amount: selectedTier.priceRaw, + sourceAccount: address, + timeoutInSeconds: 3600, + }); + setMultisigSession(session); + } catch (error) { + setStatusMessage(error instanceof Error ? error.message : "Couldn't prepare the multisig payment."); + } + }; + const handlePayment = async () => { if (!selectedTier?.priceRaw) return; @@ -526,6 +549,27 @@ export default function PremiumPage() { ? "Insufficient XLM" : paymentMethod === 'usdc' ? "Pay with Wallet" : "Proceed to Payment"} + {isConnected && ( +
+ + {isMultisig && !multisigMode && ( +

Detected {accountAuth.data?.signers.length} signers, medium threshold {accountAuth.data?.med}. Use multisig prepare for treasury payments.

+ )} + {(multisigMode || isMultisig) && ( + + )} +
+ )}

Verified on-chain by OracleDesk before access is granted

@@ -546,6 +590,13 @@ export default function PremiumPage() { onCancel={() => closeReview(false)} /> )} + {multisigSession && ( + setMultisigSession(null)} + onUpdated={(next) => setMultisigSession(next)} + /> + )} ); } diff --git a/components/multisig-review.tsx b/components/multisig-review.tsx new file mode 100644 index 0000000..af9090d --- /dev/null +++ b/components/multisig-review.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { TransactionBuilder } from "@stellar/stellar-sdk"; +import { CONTRACTS, NETWORK_PASSPHRASE } from "@/lib/stellar/config"; +import { authExpirationLedger, signatureWeight, type MultisigSession } from "@/lib/stellar/multisig"; +import { useImportMultisig, useSubmitMultisig } from "@/lib/hooks/useStellar"; +import { useWallet } from "@/lib/contexts/WalletContext"; +import { formatUsdc, formatXlm } from "@/lib/stellar/units"; + +function contractName(contractId: string): string { + if (contractId === CONTRACTS.marketCore) return "Market core"; + if (contractId === CONTRACTS.usdc) return "USDC token (Stellar Asset Contract)"; + return "Contract"; +} + +function formatArg(key: string, raw: string): string { + if (key === "collateral_in" || key === "collateral_out" || key === "min_shares_out" || key === "max_shares_in" || key === "amount") { + try { + return `${formatUsdc(BigInt(raw), { minDecimals: 7, maxDecimals: 7 })} USDC`; + } catch { + return raw; + } + } + if (key === "market_id") return `#${raw}`; + return raw; +} + +function useNowMs(stepMs = 1000): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), stepMs); + return () => clearInterval(t); + }, [stepMs]); + return now; +} + +export function MultisigReview({ + session, + onClose, + onUpdated, +}: { + session: MultisigSession; + onClose?: () => void; + onUpdated?: (next: MultisigSession) => void; +}) { + const { signer, address } = useWallet(); + const importMut = useImportMultisig(); + const submitMut = useSubmitMultisig(); + const [importText, setImportText] = useState(""); + const [copyState, setCopyState] = useState(null); + const [signState, setSignState] = useState(null); + const nowMs = useNowMs(); + const localXdr = session.xdr; + + const parsed = useMemo(() => { + try { + return TransactionBuilder.fromXDR(localXdr, NETWORK_PASSPHRASE) as import("@stellar/stellar-sdk").Transaction; + } catch { + return null; + } + }, [localXdr]); + + const weightInfo = useMemo(() => { + if (!parsed) return { weight: 0, signedBy: [] as string[], unknown: 0 }; + try { + return signatureWeight(parsed, session.auth, NETWORK_PASSPHRASE); + } catch { + return { weight: 0, signedBy: [] as string[], unknown: 0 }; + } + }, [parsed, session.auth]); + + const thresholdMet = weightInfo.weight >= session.requiredThreshold; + const secsLeft = session.maxTime - Math.floor(nowMs / 1000); + const expired = secsLeft <= 0; + const coordinatorWarn = address ? !session.auth.signers.some((s) => s.type === "ed25519" && s.key === address) : false; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(localXdr); + setCopyState("Copied. Share it with co-signers out of band."); + } catch { + setCopyState("Copy failed. Select the XDR manually."); + } + }; + + const handleImport = async () => { + setCopyState(null); + if (!importText.trim()) return; + try { + const res = await importMut.mutateAsync({ session, importedXdr: importText.trim() }); + onUpdated?.(res.session); + setImportText(""); + setCopyState( + res.unknown > 0 + ? `Imported. Weight ${res.weight}/${session.requiredThreshold}. ${res.unknown} unknown signature(s) — they cause tx_bad_auth_extra if left in.` + : `Imported. Weight ${res.weight}/${session.requiredThreshold}.`, + ); + } catch (err) { + setCopyState(err instanceof Error ? err.message : "Import failed."); + } + }; + + const handleSignWithWallet = async () => { + setSignState(null); + if (!signer) { + setSignState("Connect your wallet first."); + return; + } + if (!parsed) { + setSignState("Could not parse the current XDR."); + return; + } + try { + setSignState("Waiting for your wallet… (signature only, no submit)"); + const { signedTxXdr } = await signer.signTransaction(localXdr, { + networkPassphrase: NETWORK_PASSPHRASE, + address: signer.publicKey, + }); + const res = await importMut.mutateAsync({ session: { ...session, xdr: localXdr }, importedXdr: signedTxXdr }); + onUpdated?.(res.session); + setSignState(`Signed. Weight ${res.weight}/${session.requiredThreshold}.`); + } catch (err) { + setSignState(err instanceof Error ? err.message : "Signing failed."); + } + }; + + const handleSubmit = async () => { + try { + await submitMut.mutateAsync({ session: { ...session, xdr: localXdr } }); + onClose?.(); + } catch { + // Error surfaces via submitMut.error. + } + }; + + const submitError = submitMut.error instanceof Error ? submitMut.error.message : null; + const expiryLabel = + secsLeft <= 0 ? "Expired" : secsLeft < 3600 ? `Expires in ${Math.floor(secsLeft / 60)}m ${secsLeft % 60}s` : `Expires in ${Math.floor(secsLeft / 3600)}h ${Math.floor((secsLeft % 3600) / 60)}m`; + + return ( +
+
+
+
+

Multisig review

+

Check before collecting signatures

+
+ +
+ +
+
+

What you're doing

+
+
+
Operation
+
{session.kind}
+
+
+
Contract
+
{contractName(session.contractId)} {session.contractId}
+
+
+
Function
+
{session.fn}
+
+ {Object.entries(session.args ?? {}).map(([k, v]) => ( +
+
{k}
+
{formatArg(k, v)} {v}
+
+ ))} +
+ {(session.kind === "buy" || session.kind === "sell") && ( +

+ Quote is from ledger {session.quoteLedger ?? "N"} with {((session.args && "min_shares_out" in session.args) || session.kind === "buy") ? "3% slippage" : "3% slippage"}. The trade fails with SlippageExceeded (error 12) if the price moves further. The footprint/resource fee is from the build-time simulation; rebuild if submission fails with a resource or archived-entry error. +

+ )} +
+ +
+

Network details

+
+
+
Source account
+
{session.sourceAccount}
+
+
+
Sequence
+
{session.sequence} (account seq {session.auth.sequence.toString()}; any other transaction bumps it and invalidates this one)
+
+
+
Fee
+
{formatXlm(BigInt(session.fee))} XLM ({session.fee} stroops)
+
+
+
Expiry
+
{expiryLabel} (maxTime {session.maxTime}; checked against ledger close time, not your browser clock)
+
+ {session.closeTime !== undefined && ( +
+
Market close
+
{new Date(session.closeTime * 1000).toLocaleString()} (trade fails with MarketClosed/error 9 after this)
+
+ )} +
+
Hash
+
{session.hash}
+
+
+
+ +
+

Signatures ({weightInfo.weight}/{session.requiredThreshold})

+
+
+
+
    + {session.auth.signers.map((s) => ( +
  • + + {s.key.slice(0, 6)}…{s.key.slice(-4)} ({s.type}, w{s.weight}) {weightInfo.signedBy.includes(s.key) ? "✓" : ""} + + weight {s.weight} +
  • + ))} +
+ {session.auth.signers.some((s) => s.type !== "ed25519") && ( +

preAuthTx / hashX / signedPayload signers are listed but don't count toward UI weight.

+ )} + {weightInfo.unknown > 0 && ( +

Warning: {weightInfo.unknown} unknown/invalid signature(s). They cause tx_bad_auth_extra — strip them before submitting.

+ )} + {coordinatorWarn && ( +

The connected wallet ({address?.slice(0, 6)}…) is not one of this account's signers. You can still coordinate, but "Sign with connected wallet" will add an unknown signature.

+ )} + {session.nonInvoker.length > 0 ? ( +
+

Non-source authorization required:

+ {session.nonInvoker.map((a) => ( +

{a} — export an auth entry via signAuthEntries({"{"}address, expiration{"}"}) with expiration ledger {authExpirationLedger(0, 3600)} (current ledger + window/5s).

+ ))} +
+ ) : ( +

Non-source auth entries: none (expected — trader/holder/from is the source account).

+ )} +
+ +
+

Export / import XDR

+