diff --git a/README.md b/README.md index 35f3fa8..f800f6f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Private payments use the following environment variables: - `PAYMENTS_API_BASE_URL`: base URL for the payments API. - `CLUSTER`: cluster name passed to the payments API and used for Solana Explorer links. Supported values are `devnet`, `testnet`, and `mainnet-beta`. - `PAYMENTS_CLUSTER`: legacy fallback for the payments API cluster. `CLUSTER` takes precedence if both are set. If this value is an RPC URL containing `devnet`, `testnet`, or `mainnet`, the app infers the corresponding cluster name. +- `PAYMENTS_EPHEMERAL_RPC_URL` or `EPHEMERAL_RPC_URL`: ephemeral RPC used when signed transactions must be submitted to ER. - `NEXT_PUBLIC_PAYMENTS_TEST_USDC_MINT`: overrides the default payment mint in the UI. Example: @@ -39,6 +40,7 @@ Example: PAYMENTS_API_BASE_URL=http://localhost:8787 \ CLUSTER=devnet \ SOLANA_RPC_URL=https://rpc.magicblock.app/devnet \ +PAYMENTS_EPHEMERAL_RPC_URL=https://devnet.magicblock.app \ NEXT_PUBLIC_SOLANA_RPC_URL=https://rpc.magicblock.app/devnet \ NEXT_PUBLIC_PAYMENTS_TEST_USDC_MINT=4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU \ yarn dev -p 3002 @@ -46,6 +48,8 @@ yarn dev -p 3002 With `CLUSTER=devnet`, the app sends `cluster=devnet` to the payments API and opens transactions on `https://explorer.solana.com` with the `devnet` cluster selected. +The Handle tab creates or updates `.block` stealth pools. Payment recipients can now be a wallet address, a `.sol` name, or a `.block` handle; `.block` recipients are sent through the private stealth-transfer route. + ## Learn More To learn more, take a look at the following resources: diff --git a/app/api/payments/send/route.ts b/app/api/payments/send/route.ts new file mode 100644 index 0000000..93a550c --- /dev/null +++ b/app/api/payments/send/route.ts @@ -0,0 +1,195 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + Connection, + PublicKey, + SendTransactionError, + Transaction, + VersionedTransaction, +} from "@solana/web3.js"; +import { + createPaymentsEphemeralConnection, + createServerSolanaConnection, +} from "@/lib/solana-rpc"; + +function base64ToUint8Array(base64: string) { + const buffer = Buffer.from(base64, "base64"); + return new Uint8Array(buffer); +} + +class FeePayerFundingError extends Error { + constructor(message: string) { + super(message); + this.name = "FeePayerFundingError"; + } +} + +function getFeePayer(rawTransaction: Uint8Array) { + try { + const transaction = Transaction.from(rawTransaction); + return transaction.feePayer ?? null; + } catch { + try { + const transaction = VersionedTransaction.deserialize(rawTransaction); + return transaction.message.staticAccountKeys[0] ?? null; + } catch { + return null; + } + } +} + +async function requireFundedBaseFeePayer( + connection: Connection, + feePayer: PublicKey | null +) { + if (!feePayer) { + return; + } + + const lamports = await connection.getBalance(feePayer, "confirmed"); + if (lamports > 0) { + return; + } + + const feePayerAddress = feePayer.toBase58(); + throw new FeePayerFundingError( + `Base fee payer ${feePayerAddress} has no SOL on the configured base RPC` + ); +} + +async function getSendTransactionLogs( + error: SendTransactionError, + connection: Connection +) { + if (error.logs?.length) { + return error.logs; + } + + try { + return await error.getLogs(connection); + } catch { + return []; + } +} + +export async function POST(request: NextRequest) { + let connection: Connection | null = null; + + try { + const body = await request.json(); + const { + signedTransaction, + blockhash, + lastValidBlockHeight, + sendTo, + } = body as { + signedTransaction?: string; + blockhash?: string; + lastValidBlockHeight?: number; + sendTo?: "base" | "ephemeral"; + }; + + if ( + typeof signedTransaction !== "string" || + !signedTransaction || + typeof blockhash !== "string" || + !blockhash || + typeof lastValidBlockHeight !== "number" || + (sendTo !== "base" && sendTo !== "ephemeral") + ) { + return NextResponse.json( + { + error: + "Missing signedTransaction, blockhash, lastValidBlockHeight, or sendTo", + }, + { status: 400 } + ); + } + + const authHeader = request.headers.get("authorization") ?? ""; + const authToken = authHeader.match(/^Bearer\s+(.+)$/i)?.[1]?.trim(); + if (sendTo === "ephemeral" && !authToken) { + return NextResponse.json( + { error: "Authentication is required for ephemeral submission" }, + { status: 401 } + ); + } + + connection = + sendTo === "ephemeral" + ? createPaymentsEphemeralConnection(authToken) + : createServerSolanaConnection(); + const rawTransaction = base64ToUint8Array(signedTransaction); + if (sendTo === "base") { + await requireFundedBaseFeePayer(connection, getFeePayer(rawTransaction)); + } + + const signature = await connection.sendRawTransaction(rawTransaction, { + skipPreflight: sendTo === "ephemeral", + preflightCommitment: "confirmed", + maxRetries: 10, + }); + + const confirmation = await connection.confirmTransaction( + { + signature, + blockhash, + lastValidBlockHeight, + }, + "confirmed" + ); + + if (confirmation.value.err) { + return NextResponse.json( + { + error: "Transaction failed on-chain", + details: JSON.stringify(confirmation.value.err), + signature, + }, + { status: 400 } + ); + } + + return NextResponse.json({ signature }); + } catch (error) { + if (error instanceof FeePayerFundingError) { + return NextResponse.json( + { + error: error.message, + details: + "Fund this wallet on the same base RPC used by the Pay server, or point SOLANA_RPC_URL at the chain where the wallet is funded.", + logs: [], + }, + { status: 400 } + ); + } + + if (error instanceof SendTransactionError && connection) { + const logs = await getSendTransactionLogs(error, connection); + const transactionError = error.transactionError; + const message = transactionError.message || error.message; + + console.error("Payments send transaction error:", { + message, + logs, + }); + + return NextResponse.json( + { + error: message, + details: logs.length > 0 ? logs.join("\n") : error.message, + logs, + }, + { status: 400 } + ); + } + + console.error("Payments send error:", error); + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Failed to send transaction", + }, + { status: 502 } + ); + } +} diff --git a/app/api/payments/stealth-pool/route.ts b/app/api/payments/stealth-pool/route.ts new file mode 100644 index 0000000..894d4d7 --- /dev/null +++ b/app/api/payments/stealth-pool/route.ts @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PublicKey } from "@solana/web3.js"; +import { + PAYMENTS_CLUSTER, + PAYMENTS_ENDPOINTS, + getPaymentsApiUrl, + getPaymentsTimeoutSignal, +} from "@/lib/payments"; +import { getPaymentsErrorMessage } from "@/lib/payments-errors"; +import { + STEALTH_POOL_MAX_DESTINATIONS, + getExactStealthHandleInput, + isStealthHandleInput, +} from "@/lib/stealth-handles"; + +interface StealthPoolBuildRequest { + payer?: string; + authority?: string; + handle?: string; + destinations?: string[]; + splitAcrossKeys?: boolean; +} + +export async function GET(request: NextRequest) { + try { + const handle = getExactStealthHandleInput( + request.nextUrl.searchParams.get("handle") ?? "" + ); + if (!handle || !isStealthHandleInput(handle)) { + return NextResponse.json( + { error: "Missing or invalid .block handle" }, + { status: 400 } + ); + } + + const upstreamUrl = new URL(getPaymentsApiUrl(PAYMENTS_ENDPOINTS.stealthPool)); + upstreamUrl.searchParams.set("handle", handle); + if (PAYMENTS_CLUSTER) { + upstreamUrl.searchParams.set("cluster", PAYMENTS_CLUSTER); + } + + const upstreamRes = await fetch(upstreamUrl, { + method: "GET", + signal: getPaymentsTimeoutSignal(), + cache: "no-store", + }); + + const responseBody = await upstreamRes.json().catch(() => null); + if (!upstreamRes.ok) { + return NextResponse.json( + { + error: getPaymentsErrorMessage(upstreamRes.status, responseBody), + details: responseBody, + }, + { status: upstreamRes.status } + ); + } + + return NextResponse.json(responseBody); + } catch (error) { + console.error("Payments stealth pool status error:", error); + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Failed to fetch stealth pool status", + }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const authHeader = request.headers.get("authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return NextResponse.json( + { error: "Missing private-session auth token" }, + { status: 401 } + ); + } + + const body = (await request.json()) as StealthPoolBuildRequest; + const handle = + typeof body.handle === "string" + ? getExactStealthHandleInput(body.handle) + : ""; + + if ( + typeof body.payer !== "string" || + typeof body.authority !== "string" || + !handle || + !isStealthHandleInput(handle) || + !Array.isArray(body.destinations) || + body.destinations.length < 1 || + body.destinations.length > STEALTH_POOL_MAX_DESTINATIONS || + (body.splitAcrossKeys !== undefined && + typeof body.splitAcrossKeys !== "boolean") + ) { + return NextResponse.json( + { error: "Missing or invalid stealth pool parameters" }, + { status: 400 } + ); + } + + try { + new PublicKey(body.payer); + new PublicKey(body.authority); + body.destinations.forEach((destination) => new PublicKey(destination)); + } catch { + return NextResponse.json( + { error: "Invalid payer, authority, or destination public key" }, + { status: 400 } + ); + } + + const upstreamRes = await fetch(getPaymentsApiUrl(PAYMENTS_ENDPOINTS.stealthPool), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + body: JSON.stringify({ + payer: body.payer, + authority: body.authority, + handle, + destinations: body.destinations, + ...(body.splitAcrossKeys !== undefined + ? { splitAcrossKeys: body.splitAcrossKeys } + : {}), + ...(PAYMENTS_CLUSTER ? { cluster: PAYMENTS_CLUSTER } : {}), + }), + signal: getPaymentsTimeoutSignal(), + cache: "no-store", + }); + + const responseBody = await upstreamRes.json().catch(() => null); + if (!upstreamRes.ok) { + return NextResponse.json( + { + error: getPaymentsErrorMessage(upstreamRes.status, responseBody), + details: responseBody, + }, + { status: upstreamRes.status } + ); + } + + return NextResponse.json(responseBody); + } catch (error) { + console.error("Payments stealth pool build error:", error); + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Failed to build stealth pool transaction", + }, + { status: 500 } + ); + } +} diff --git a/app/api/payments/transfer-stealth/route.ts b/app/api/payments/transfer-stealth/route.ts new file mode 100644 index 0000000..845755e --- /dev/null +++ b/app/api/payments/transfer-stealth/route.ts @@ -0,0 +1,168 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PublicKey } from "@solana/web3.js"; +import { + PAYMENTS_CLUSTER, + PAYMENTS_ENDPOINTS, + getPaymentsApiUrl, + getPaymentsTimeoutSignal, +} from "@/lib/payments"; +import { getPaymentsErrorMessage } from "@/lib/payments-errors"; +import { + getExactStealthHandleInput, + isStealthHandleInput, +} from "@/lib/stealth-handles"; + +interface StealthTransferBuildRequest { + from?: string; + toHandle?: string; + mint?: string; + amount?: string; + gasless?: boolean; + memo?: string; + exactOut?: boolean; + minDelayMs?: string; + maxDelayMs?: string; + split?: number; +} + +const MAX_PRIVATE_DELAY_MS = BigInt(30 * 60 * 1000); + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as StealthTransferBuildRequest; + const { + from, + mint, + amount, + gasless, + memo, + exactOut, + minDelayMs, + maxDelayMs, + split, + } = body; + const toHandle = + typeof body.toHandle === "string" + ? getExactStealthHandleInput(body.toHandle) + : ""; + + if ( + typeof from !== "string" || + typeof mint !== "string" || + typeof amount !== "string" || + !toHandle || + !isStealthHandleInput(toHandle) || + (gasless !== undefined && typeof gasless !== "boolean") || + (memo !== undefined && typeof memo !== "string") || + (exactOut !== undefined && typeof exactOut !== "boolean") || + (minDelayMs !== undefined && + (typeof minDelayMs !== "string" || !/^\d+$/.test(minDelayMs))) || + (maxDelayMs !== undefined && + (typeof maxDelayMs !== "string" || !/^\d+$/.test(maxDelayMs))) || + (split !== undefined && + (!Number.isInteger(split) || split < 1 || split > 10)) + ) { + return NextResponse.json( + { error: "Missing or invalid stealth transfer parameters" }, + { status: 400 } + ); + } + + try { + new PublicKey(from); + new PublicKey(mint); + } catch { + return NextResponse.json( + { error: "Invalid from or mint public key" }, + { status: 400 } + ); + } + + if (!/^[1-9]\d*$/.test(amount)) { + return NextResponse.json( + { error: "amount must be a positive integer string" }, + { status: 400 } + ); + } + + const amountBigInt = BigInt(amount); + if (amountBigInt > BigInt(Number.MAX_SAFE_INTEGER)) { + return NextResponse.json( + { error: "amount exceeds the maximum supported integer size" }, + { status: 400 } + ); + } + + if ( + minDelayMs !== undefined && + maxDelayMs !== undefined && + BigInt(maxDelayMs) < BigInt(minDelayMs) + ) { + return NextResponse.json( + { error: "maxDelayMs must be greater than or equal to minDelayMs" }, + { status: 400 } + ); + } + + if ( + (minDelayMs !== undefined && BigInt(minDelayMs) > MAX_PRIVATE_DELAY_MS) || + (maxDelayMs !== undefined && BigInt(maxDelayMs) > MAX_PRIVATE_DELAY_MS) + ) { + return NextResponse.json( + { error: "minDelayMs and maxDelayMs must be less than or equal to 1800000" }, + { status: 400 } + ); + } + + const upstreamRes = await fetch( + getPaymentsApiUrl(PAYMENTS_ENDPOINTS.splTransferStealth), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from, + toHandle, + ...(PAYMENTS_CLUSTER ? { cluster: PAYMENTS_CLUSTER } : {}), + mint, + amount: Number(amountBigInt), + fromBalance: "base", + initIfMissing: true, + initAtasIfMissing: true, + initVaultIfMissing: false, + ...(gasless === true ? { gasless: true } : {}), + ...(memo ? { memo } : {}), + ...(exactOut !== undefined ? { exactOut } : {}), + ...(minDelayMs !== undefined ? { minDelayMs } : {}), + ...(maxDelayMs !== undefined ? { maxDelayMs } : {}), + ...(split !== undefined ? { split } : {}), + }), + signal: getPaymentsTimeoutSignal(), + cache: "no-store", + } + ); + + const responseBody = await upstreamRes.json().catch(() => null); + if (!upstreamRes.ok) { + return NextResponse.json( + { + error: getPaymentsErrorMessage(upstreamRes.status, responseBody), + details: responseBody, + }, + { status: upstreamRes.status } + ); + } + + return NextResponse.json(responseBody); + } catch (error) { + console.error("Payments stealth transfer build error:", error); + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Failed to build stealth transfer transaction", + }, + { status: 500 } + ); + } +} diff --git a/components/one/handle-card.tsx b/components/one/handle-card.tsx new file mode 100644 index 0000000..5a7d808 --- /dev/null +++ b/components/one/handle-card.tsx @@ -0,0 +1,590 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + AlertTriangle, + Check, + ExternalLink, + Loader2, + Plus, + ShieldCheck, + Trash2, +} from "lucide-react"; +import bs58 from "bs58"; +import { PublicKey } from "@solana/web3.js"; + +import { useUnifiedWallet } from "@/app/wallet/solana-wallet-provider"; +import { + PaymentTransactionSubmissionError, + type UnsignedPaymentTransaction, + deserializeUnsignedPaymentTransaction, + isPaymentBlockhashExpiredError, + submitSignedPaymentTransaction, +} from "@/lib/payment-transactions"; +import { + clearStoredPrivateAuthToken, + fetchSplChallenge, + getStoredPrivateAuthToken, + loginSplPrivate, + setStoredPrivateAuthToken, +} from "@/lib/spl-private-balance"; +import { + STEALTH_POOL_MAX_DESTINATIONS, + getExactStealthHandleInput, + getStoredStealthHandle, + isStealthHandleInput, + setStoredStealthHandle, +} from "@/lib/stealth-handles"; + +type SaveStatus = "idle" | "checking" | "building" | "signing" | "sending" | "confirmed" | "error"; + +interface StealthPoolStatusResponse { + stealthPool: string; + handleHash: string; + exists: boolean; +} + +type StealthPoolBuildResponse = Partial & + StealthPoolStatusResponse & { + ensureStealthPoolDelegatedTransaction?: UnsignedPaymentTransaction; + updateStealthPoolTransaction?: UnsignedPaymentTransaction; + setupTransaction?: UnsignedPaymentTransaction; + transactions?: UnsignedPaymentTransaction[]; + }; + +function isUnsignedPaymentTransaction( + value: unknown +): value is UnsignedPaymentTransaction { + if (!value || typeof value !== "object") return false; + + const transaction = value as Partial; + return ( + typeof transaction.kind === "string" && + typeof transaction.transactionBase64 === "string" && + (transaction.sendTo === "base" || transaction.sendTo === "ephemeral") && + typeof transaction.recentBlockhash === "string" && + typeof transaction.lastValidBlockHeight === "number" && + Array.isArray(transaction.requiredSigners) + ); +} + +function normalizeTransactionKind(transaction: UnsignedPaymentTransaction) { + return transaction.kind.toLowerCase().replace(/[\s_-]/g, ""); +} + +function findTransactionByKind( + transactions: UnsignedPaymentTransaction[] | undefined, + kind: string +) { + return transactions?.find((transaction) => + normalizeTransactionKind(transaction).includes(kind) + ); +} + +function getStealthPoolSaveTransactions(response: StealthPoolBuildResponse) { + const record = response as unknown as Record; + const ensureTransaction = + response.ensureStealthPoolDelegatedTransaction ?? + (isUnsignedPaymentTransaction(record.ensureStealthPoolDelegated) + ? record.ensureStealthPoolDelegated + : undefined) ?? + (isUnsignedPaymentTransaction(record.ensureDelegatedTransaction) + ? record.ensureDelegatedTransaction + : undefined) ?? + (isUnsignedPaymentTransaction(record.ensureTransaction) + ? record.ensureTransaction + : undefined) ?? + response.setupTransaction ?? + (isUnsignedPaymentTransaction(record.baseTransaction) + ? record.baseTransaction + : undefined) ?? + findTransactionByKind(response.transactions, "ensurestealthpooldelegated"); + + const updateTransaction = + response.updateStealthPoolTransaction ?? + (isUnsignedPaymentTransaction(record.updateStealthPool) + ? record.updateStealthPool + : undefined) ?? + (isUnsignedPaymentTransaction(record.updateTransaction) + ? record.updateTransaction + : undefined) ?? + (isUnsignedPaymentTransaction(record.ephemeralTransaction) + ? record.ephemeralTransaction + : undefined) ?? + findTransactionByKind(response.transactions, "updatestealthpool") ?? + (isUnsignedPaymentTransaction(response) ? response : undefined); + + if (!ensureTransaction) { + throw new Error("Backend did not return EnsureStealthPoolDelegated transaction"); + } + if (!updateTransaction) { + throw new Error("Backend did not return UpdateStealthPool transaction"); + } + if (ensureTransaction.sendTo !== "base") { + throw new Error("EnsureStealthPoolDelegated transaction must target base"); + } + if (updateTransaction.sendTo !== "ephemeral") { + throw new Error("UpdateStealthPool transaction must target ER"); + } + + return { ensureTransaction, updateTransaction }; +} + +function requireWalletSigner( + transaction: UnsignedPaymentTransaction, + owner: string, + label: string +) { + if (!transaction.requiredSigners.includes(owner)) { + throw new Error(`Wallet is not listed as a required ${label} signer`); + } +} + +export function HandleCard() { + const { connected, openConnectModal, publicKey, signMessage, signTransaction } = + useUnifiedWallet(); + const owner = publicKey?.toBase58() ?? ""; + + const [handle, setHandle] = useState(""); + const [destinations, setDestinations] = useState([]); + const [splitAcrossKeys, setSplitAcrossKeys] = useState(false); + const [status, setStatus] = useState("idle"); + const [poolStatus, setPoolStatus] = useState(null); + const [signature, setSignature] = useState(null); + const [error, setError] = useState(null); + + const exactHandle = getExactStealthHandleInput(handle); + const isValidHandle = isStealthHandleInput(exactHandle); + + const destinationErrors = useMemo(() => { + return destinations.map((destination) => { + try { + new PublicKey(destination.trim()); + return null; + } catch { + return "Invalid owner key"; + } + }); + }, [destinations]); + + const hasValidDestinations = + destinations.length >= 1 && + destinations.length <= STEALTH_POOL_MAX_DESTINATIONS && + destinationErrors.every((destinationError) => destinationError === null); + + useEffect(() => { + if (!owner) { + setDestinations([]); + return; + } + + setHandle(getStoredStealthHandle(owner) ?? ""); + setDestinations([owner]); + setPoolStatus(null); + setSignature(null); + setError(null); + setStatus("idle"); + }, [owner]); + + const resetResultState = useCallback(() => { + setPoolStatus(null); + setSignature(null); + setError(null); + setStatus((currentStatus) => + currentStatus === "confirmed" || currentStatus === "error" + ? "idle" + : currentStatus + ); + }, []); + + const checkPoolStatus = useCallback(async () => { + if (!isValidHandle) return; + + setStatus("checking"); + setError(null); + setPoolStatus(null); + + try { + const res = await fetch( + `/api/payments/stealth-pool?handle=${encodeURIComponent(exactHandle)}`, + { cache: "no-store" } + ); + const body = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(body?.error || `Status check failed: ${res.status}`); + } + setPoolStatus(body as StealthPoolStatusResponse); + setStatus("idle"); + } catch (err) { + const message = err instanceof Error ? err.message : "Status check failed"; + setError(message); + setStatus("error"); + } + }, [exactHandle, isValidHandle]); + + const saveHandle = useCallback(async () => { + if (!owner || !publicKey || !signTransaction || !signMessage || !connected) { + return; + } + if (!isValidHandle || !hasValidDestinations) return; + + setError(null); + setSignature(null); + + try { + const getAuthToken = async () => { + const storedToken = getStoredPrivateAuthToken(owner); + if (storedToken) return storedToken; + + const challenge = await fetchSplChallenge(owner); + const message = new TextEncoder().encode(challenge); + const sigBytes = await signMessage(message); + const token = await loginSplPrivate({ + pubkey: owner, + challenge, + signature: bs58.encode(sigBytes), + }); + setStoredPrivateAuthToken(owner, token); + return token; + }; + + let body: StealthPoolBuildResponse | null = null; + let nextSignature = ""; + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + setStatus("building"); + const authToken = await getAuthToken(); + + const res = await fetch("/api/payments/stealth-pool", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + payer: owner, + authority: owner, + handle: exactHandle, + destinations: destinations.map((destination) => destination.trim()), + splitAcrossKeys, + }), + }); + const responseBody = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(responseBody?.error || `Build failed: ${res.status}`); + } + + const buildResponse = responseBody as StealthPoolBuildResponse; + const { ensureTransaction, updateTransaction } = + getStealthPoolSaveTransactions(buildResponse); + console.log("Save handle stealth pool", { + handle: exactHandle, + stealthPool: buildResponse.stealthPool, + handleHash: buildResponse.handleHash, + ensureKind: ensureTransaction.kind, + updateKind: updateTransaction.kind, + }); + + requireWalletSigner(ensureTransaction, owner, "ensure delegation"); + requireWalletSigner(updateTransaction, owner, "update pool"); + + setStatus("signing"); + const setupTransaction = deserializeUnsignedPaymentTransaction( + ensureTransaction + ); + const signedSetupTransaction = await signTransaction(setupTransaction); + const updatePoolTransaction = + deserializeUnsignedPaymentTransaction(updateTransaction); + const signedUpdatePoolTransaction = + await signTransaction(updatePoolTransaction); + + setStatus("sending"); + const ensureSignature = await submitSignedPaymentTransaction( + ensureTransaction, + signedSetupTransaction + ); + setSignature(ensureSignature); + nextSignature = await submitSignedPaymentTransaction( + updateTransaction, + signedUpdatePoolTransaction, + authToken + ); + body = buildResponse; + break; + } catch (err) { + if ( + err instanceof PaymentTransactionSubmissionError && + err.status === 401 + ) { + clearStoredPrivateAuthToken(owner); + } + if (attempt === 0 && isPaymentBlockhashExpiredError(err)) { + setSignature(null); + continue; + } + + throw err; + } + } + + if (!body) { + throw new Error("Failed to save handle"); + } + + setStoredStealthHandle(owner, exactHandle); + setSignature(nextSignature); + setPoolStatus({ + stealthPool: body.stealthPool, + handleHash: body.handleHash, + exists: true, + }); + setStatus("confirmed"); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to save handle"; + if (err instanceof PaymentTransactionSubmissionError && err.signature) { + setSignature(err.signature); + } + if (message.includes("User rejected")) { + setError("Transaction rejected by user"); + } else { + setError(message); + } + setStatus("error"); + } + }, [ + connected, + destinations, + exactHandle, + hasValidDestinations, + isValidHandle, + owner, + publicKey, + signMessage, + signTransaction, + splitAcrossKeys, + ]); + + const addDestination = useCallback(() => { + if (destinations.length >= STEALTH_POOL_MAX_DESTINATIONS) return; + setDestinations((current) => [...current, owner]); + resetResultState(); + }, [destinations.length, owner, resetResultState]); + + const removeDestination = useCallback( + (index: number) => { + setDestinations((current) => current.filter((_, i) => i !== index)); + resetResultState(); + }, + [resetResultState] + ); + + const updateDestination = useCallback( + (index: number, value: string) => { + setDestinations((current) => + current.map((destination, i) => (i === index ? value : destination)) + ); + resetResultState(); + }, + [resetResultState] + ); + + const isBusy = + status === "checking" || + status === "building" || + status === "signing" || + status === "sending"; + + const saveLabel = + status === "building" + ? "Preparing..." + : status === "signing" + ? "Waiting for wallet..." + : status === "sending" + ? "Saving..." + : "Save handle"; + + return ( +
+
+
+
+
Stealth handle
+ { + setHandle(event.target.value); + resetResultState(); + }} + placeholder="gm.block" + className="w-full bg-transparent font-mono text-lg text-foreground placeholder:text-muted-foreground/40 outline-none" + /> + {handle && !isValidHandle && ( +
+ + Use a lowercase .block handle +
+ )} + {poolStatus && ( +
+ {poolStatus.exists ? "Existing pool" : "New pool"}{" "} + + {poolStatus.stealthPool.slice(0, 4)}... + {poolStatus.stealthPool.slice(-4)} + +
+ )} +
+
+ +
+
+
+
+
+ Backing owner keys +
+
+ {destinations.length}/{STEALTH_POOL_MAX_DESTINATIONS} +
+
+ +
+ +
+ {destinations.map((destination, index) => ( +
+
+ + updateDestination(index, event.target.value) + } + className="min-w-0 flex-1 rounded-lg border border-border/40 bg-secondary/20 px-3 py-2 font-mono text-xs text-foreground outline-none transition-colors focus:border-border" + /> + +
+ {destinationErrors[index] && ( +
+ {destinationErrors[index]} +
+ )} +
+ ))} +
+
+
+ +
+
+ +
+
+ + {error && ( +
+ + {error} + + {signature && ( + + View tx + + + )} +
+ )} + + {status === "confirmed" && signature && ( +
+
+ + Handle saved +
+ + View tx + + +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/components/one/payment-card.tsx b/components/one/payment-card.tsx index 526469e..8af3e04 100644 --- a/components/one/payment-card.tsx +++ b/components/one/payment-card.tsx @@ -9,7 +9,6 @@ import { CircleHelp, ChevronDown, Settings2, - Shield, ShieldCheck, User, AlertTriangle, @@ -37,6 +36,11 @@ import { dispatchPrivateBalanceRefresh, } from "@/lib/private-balance-refresh"; import { PAYMENTS_DEFAULT_USDC_MINT } from "@/lib/payments"; +import { + PaymentTransactionSubmissionError, + type UnsignedPaymentTransaction, + deserializeUnsignedPaymentTransaction, +} from "@/lib/payment-transactions"; import { clearStoredPrivateAuthToken, fetchPrivateBalance, @@ -52,8 +56,11 @@ import { clampPrivateSplit, formatPrivateRoutingSummary, } from "@/lib/private-routing"; +import { + getExactStealthHandleInput, + isStealthHandleInput, +} from "@/lib/stealth-handles"; import { PrivateRoutingControls } from "./private-routing-controls"; -import { Slider } from "@/components/ui/slider"; import { Popover, PopoverContent, @@ -77,18 +84,6 @@ type PaymentStatus = | "error"; type BalanceLocation = "base" | "ephemeral"; -interface UnsignedPaymentTransaction { - kind: string; - version?: "legacy" | "v0" | 0 | "0"; - transactionBase64: string; - sendTo: "base" | "ephemeral"; - recentBlockhash: string; - lastValidBlockHeight: number; - instructionCount: number; - requiredSigners: string[]; - validator?: string; -} - interface SignedPaymentTransactionResponse { confirmationRequiresAuthToken?: boolean; confirmationRpcEndpoint?: string; @@ -110,17 +105,6 @@ const REQUEST_QUERY_PARAMS = ["prd", "ramt", "rmint"] as const; const SPL_TOKEN_ACCOUNT_AMOUNT_OFFSET = 64; const SPL_TOKEN_ACCOUNT_AMOUNT_LENGTH = 8; -function base64ToUint8Array(base64: string) { - const binary = globalThis.atob(base64); - const bytes = new Uint8Array(binary.length); - - for (let i = 0; i < binary.length; i += 1) { - bytes[i] = binary.charCodeAt(i); - } - - return bytes; -} - function uint8ArrayToBase64(bytes: Uint8Array) { let binary = ""; @@ -131,41 +115,6 @@ function uint8ArrayToBase64(bytes: Uint8Array) { return globalThis.btoa(binary); } -function deserializeUnsignedPaymentTransaction( - unsignedTransaction: UnsignedPaymentTransaction -) { - const transactionBytes = base64ToUint8Array( - unsignedTransaction.transactionBase64 - ); - - if ( - unsignedTransaction.version === undefined || - unsignedTransaction.version === null - ) { - try { - return Transaction.from(transactionBytes); - } catch { - return VersionedTransaction.deserialize(transactionBytes); - } - } - - if (unsignedTransaction.version === "legacy") { - return Transaction.from(transactionBytes); - } - - if ( - unsignedTransaction.version === "v0" || - unsignedTransaction.version === 0 || - unsignedTransaction.version === "0" - ) { - return VersionedTransaction.deserialize(transactionBytes); - } - - throw new Error( - `Unsupported transaction version: ${unsignedTransaction.version}` - ); -} - function preparePaymentTransactionForSigning( transaction: Transaction | VersionedTransaction, recentBlockhash: string @@ -483,19 +432,23 @@ export function PaymentCard() { [amount, selectedToken.decimals] ); - const trimmedReceiver = receiver.trim(); + const trimmedReceiver = getExactStealthHandleInput(receiver); + const isStealthReceiver = isStealthHandleInput(trimmedReceiver); const directReceiverAddress = useMemo( () => getRecipientAddress(trimmedReceiver), [trimmedReceiver] ); const isDomainReceiver = Boolean( - trimmedReceiver && !directReceiverAddress && looksLikeDomain(trimmedReceiver) + trimmedReceiver && + !directReceiverAddress && + !isStealthReceiver && + looksLikeDomain(trimmedReceiver) ); const resolvedReceiver = directReceiverAddress ?? resolvedDomainAddress; const isValidReceiver = useMemo(() => { - return Boolean(resolvedReceiver); - }, [resolvedReceiver]); + return isStealthReceiver || Boolean(resolvedReceiver); + }, [isStealthReceiver, resolvedReceiver]); const routingSummary = useMemo(() => { return formatPrivateRoutingSummary(split, minDelayMs, maxDelayMs); @@ -525,6 +478,11 @@ export function PaymentCard() { ); }, [searchMint, tokens]); + useEffect(() => { + if (!isStealthReceiver || isPrivate) return; + setIsPrivate(true); + }, [isStealthReceiver, isPrivate]); + useEffect(() => { let cancelled = false; @@ -1280,7 +1238,8 @@ export function PaymentCard() { const handleSend = useCallback(async () => { if (!publicKey || !signTransaction || !connected) return; - if (!resolvedReceiver || isResolvingRecipient) return; + if (isResolvingRecipient) return; + if (!isStealthReceiver && !resolvedReceiver) return; if (!rawAmount || rawAmount === "0") return; setStatus("building"); @@ -1289,29 +1248,39 @@ export function PaymentCard() { setTxExplorerRpcEndpoint(null); try { - const buildRes = await fetch("/api/payments/transfer", { + const buildRes = await fetch( + isStealthReceiver + ? "/api/payments/transfer-stealth" + : "/api/payments/transfer", + { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ from: publicKey.toBase58(), - to: resolvedReceiver, + ...(isStealthReceiver + ? { toHandle: trimmedReceiver } + : { to: resolvedReceiver }), mint: tokenMint, amount: rawAmount, - visibility: - isPrivate || - sourceBalance === "ephemeral" || - recipientBalance === "ephemeral" - ? "private" - : "public", - fromBalance: sourceBalance, - toBalance: recipientBalance, - ...(sourceBalance === "ephemeral" && privateAuthToken - ? { authToken: privateAuthToken } - : {}), + ...(isStealthReceiver + ? {} + : { + visibility: + isPrivate || + sourceBalance === "ephemeral" || + recipientBalance === "ephemeral" + ? "private" + : "public", + fromBalance: sourceBalance, + toBalance: recipientBalance, + ...(sourceBalance === "ephemeral" && privateAuthToken + ? { authToken: privateAuthToken } + : {}), + }), ...(isGasless ? { gasless: true } : {}), ...(memo ? { memo } : {}), exactOut: effectiveExactOut, - ...(isPrivate + ...(isPrivate || isStealthReceiver ? { minDelayMs: String(minDelayMs), maxDelayMs: String(maxDelayMs), @@ -1319,7 +1288,8 @@ export function PaymentCard() { } : {}), }), - }); + } + ); if (!buildRes.ok) { @@ -1346,6 +1316,9 @@ export function PaymentCard() { dispatchPrivateBalanceRefresh(); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Payment failed"; + if (err instanceof PaymentTransactionSubmissionError && err.signature) { + setTxSignature(err.signature); + } if (message.includes("User rejected")) { setError("Transaction rejected by user"); } else { @@ -1360,6 +1333,8 @@ export function PaymentCard() { isValidReceiver, rawAmount, resolvedReceiver, + trimmedReceiver, + isStealthReceiver, tokenMint, isPrivate, sourceBalance, @@ -1371,7 +1346,6 @@ export function PaymentCard() { minDelayMs, maxDelayMs, split, - connection, isResolvingRecipient, signAndSendUnsignedTransaction, ]); @@ -1583,10 +1557,15 @@ export function PaymentCard() { setReceiver(e.target.value); resetResultState(); }} - placeholder="Solana wallet address or .sol domain" + placeholder="Wallet address, .sol domain, or .block handle" className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/40 outline-none font-mono" /> + {receiver && isStealthReceiver && ( +
+ MagicBlock stealth handle. This payment will use private routing. +
+ )} {receiver && isResolvingRecipient && (
Resolving domain... @@ -1606,7 +1585,7 @@ export function PaymentCard() {
)} - {receiver && !isResolvingRecipient && isValidReceiver && ( + {receiver && !isStealthReceiver && !isResolvingRecipient && isValidReceiver && (
Public:{" "} {isRecipientTokenBalanceLoading @@ -1619,7 +1598,7 @@ export function PaymentCard() { {receiver && !isResolvingRecipient && !isValidReceiver && !isDomainReceiver && (
- Invalid Solana address + Invalid recipient
)}
@@ -1674,13 +1653,26 @@ export function PaymentCard() { id="private-transfer-toggle" label="Shielded transfer" enabled={isPrivate} - onEnabledChange={handlePrivateRoutingChange} + onEnabledChange={(enabled) => { + if (isStealthReceiver && !enabled) { + resetResultState(); + setIsPrivate(true); + return; + } + handlePrivateRoutingChange(enabled); + }} summary={ - recipientBalance === "ephemeral" + isStealthReceiver + ? `Stealth handle ยท ${routingSummary}` + : recipientBalance === "ephemeral" ? "Shielded balance delivery" : routingSummary } - disabledDescription="Enable MagicBlock shielded transactions" + disabledDescription={ + isStealthReceiver + ? "Stealth handles always use private routing" + : "Enable MagicBlock shielded transactions" + } minDelayMs={minDelayMs} maxDelayMs={maxDelayMs} onDelayRangeChange={handleDelayRangeChange} @@ -1835,7 +1827,9 @@ export function PaymentCard() { ) : ( - {error} + + {error} + )} {errorTxSignature && !errorTransactionSignature && ( - Enter recipient address + Enter recipient ); } @@ -2045,7 +2039,7 @@ function PaymentActionButton({ disabled className="w-full py-4 rounded-xl bg-secondary text-muted-foreground font-semibold text-base cursor-not-allowed" > - Invalid recipient address + Invalid recipient ); } diff --git a/components/one/trade-hub.tsx b/components/one/trade-hub.tsx index 05d5dbb..8354e23 100644 --- a/components/one/trade-hub.tsx +++ b/components/one/trade-hub.tsx @@ -4,15 +4,17 @@ import { useCallback, useEffect, useState } from "react"; import { AlertTriangle, ArrowLeftRight, + AtSign, + QrCode, Send, Shield as ShieldIcon, - QrCode, } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { SwapCard } from "./swap-card"; import { PaymentCard } from "./payment-card"; import { ShieldCard } from "./shield-card"; import { RequestCard } from "./request-card"; +import { HandleCard } from "./handle-card"; import { Tooltip, TooltipContent, @@ -24,6 +26,7 @@ const topTabs = [ { id: "swap", label: "Swap", icon: ArrowLeftRight }, { id: "shield", label: "Shield", icon: ShieldIcon }, { id: "request", label: "Request", icon: QrCode }, + { id: "handle", label: "Handle", icon: AtSign }, ] as const; const SWAP_QUERY_PARAMS = [ @@ -49,6 +52,7 @@ const PAYMENT_QUERY_PARAMS = [ ] as const; const REQUEST_QUERY_PARAMS = ["prd", "ramt", "rmint"] as const; const SHIELD_QUERY_PARAMS = ["shamt", "shmint"] as const; +const HANDLE_QUERY_PARAMS = ["h"] as const; type TopTab = (typeof topTabs)[number]["id"]; @@ -57,7 +61,8 @@ function isTopTab(value: string | null): value is TopTab { value === "payment" || value === "swap" || value === "shield" || - value === "request" + value === "request" || + value === "handle" ); } @@ -110,6 +115,7 @@ export function TradeHub({ const hasShieldSelection = Boolean( searchParams.get("shamt") || searchParams.get("shmint") ); + const hasHandleSelection = Boolean(searchParams.get("h")); const [activeTop, setActiveTop] = useState( selectableUrlTab ? selectableUrlTab @@ -119,9 +125,11 @@ export function TradeHub({ ? "request" : hasShieldSelection ? "shield" - : hasSwapSelection && !isSwapDisabled - ? "swap" - : "payment" + : hasHandleSelection + ? "handle" + : hasSwapSelection && !isSwapDisabled + ? "swap" + : "payment" ); const showPrivatePaymentsNotice = activeTop === "payment" && !searchParams.has("public"); @@ -147,6 +155,11 @@ export function TradeHub({ return; } + if (hasHandleSelection) { + setActiveTop("handle"); + return; + } + if (hasSwapSelection && !isSwapDisabled) { setActiveTop("swap"); return; @@ -158,6 +171,7 @@ export function TradeHub({ hasPaymentSelection, hasRequestSelection, hasShieldSelection, + hasHandleSelection, hasSwapSelection, isSwapDisabled, ]); @@ -171,24 +185,35 @@ export function TradeHub({ ...PAYMENT_QUERY_PARAMS, ...REQUEST_QUERY_PARAMS, ...SHIELD_QUERY_PARAMS, + ...HANDLE_QUERY_PARAMS, ] : tab === "payment" ? [ ...SWAP_QUERY_PARAMS, ...REQUEST_QUERY_PARAMS, ...SHIELD_QUERY_PARAMS, + ...HANDLE_QUERY_PARAMS, ] : tab === "shield" ? [ ...SWAP_QUERY_PARAMS, ...PAYMENT_QUERY_PARAMS, ...REQUEST_QUERY_PARAMS, + ...HANDLE_QUERY_PARAMS, ] - : [ - ...SWAP_QUERY_PARAMS, - ...PAYMENT_QUERY_PARAMS, - ...SHIELD_QUERY_PARAMS, - ]; + : tab === "handle" + ? [ + ...SWAP_QUERY_PARAMS, + ...PAYMENT_QUERY_PARAMS, + ...REQUEST_QUERY_PARAMS, + ...SHIELD_QUERY_PARAMS, + ] + : [ + ...SWAP_QUERY_PARAMS, + ...PAYMENT_QUERY_PARAMS, + ...SHIELD_QUERY_PARAMS, + ...HANDLE_QUERY_PARAMS, + ]; paramsToRemove.forEach((key) => params.delete(key)); if (tab === "payment") { @@ -287,6 +312,7 @@ export function TradeHub({ {activeTop === "payment" && } {activeTop === "shield" && } {activeTop === "request" && } + {activeTop === "handle" && } {showPrivatePaymentsNotice && (
diff --git a/lib/payment-transactions.ts b/lib/payment-transactions.ts new file mode 100644 index 0000000..876c224 --- /dev/null +++ b/lib/payment-transactions.ts @@ -0,0 +1,160 @@ +import { Transaction, VersionedTransaction } from "@solana/web3.js"; + +export interface UnsignedPaymentTransaction { + kind: string; + version?: "legacy" | "v0" | 0 | "0"; + transactionBase64: string; + sendTo: "base" | "ephemeral"; + recentBlockhash: string; + lastValidBlockHeight: number; + instructionCount: number; + requiredSigners: string[]; + validator?: string; +} + +interface PaymentTransactionSubmissionErrorOptions { + status: number; + signature?: string; + details?: string; +} + +export class PaymentTransactionSubmissionError extends Error { + status: number; + signature?: string; + details?: string; + + constructor( + message: string, + { status, signature, details }: PaymentTransactionSubmissionErrorOptions + ) { + const signatureSuffix = signature ? ` (tx: ${signature})` : ""; + const detailsSuffix = details ? `: ${details}` : ""; + super(`${message}${signatureSuffix}${detailsSuffix}`); + this.name = "PaymentTransactionSubmissionError"; + this.status = status; + this.signature = signature; + this.details = details; + } +} + +export function isPaymentBlockhashExpiredError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const details = + error instanceof PaymentTransactionSubmissionError && error.details + ? error.details + : ""; + const normalized = `${message} ${details}`.toLowerCase(); + + return ( + normalized.includes("block height exceeded") || + normalized.includes("blockhash expired") || + normalized.includes("blockhash not found") || + normalized.includes("transaction expired") + ); +} + +function base64ToUint8Array(base64: string) { + const binary = globalThis.atob(base64); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes; +} + +function uint8ArrayToBase64(bytes: Uint8Array) { + let binary = ""; + + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return globalThis.btoa(binary); +} + +export function deserializeUnsignedPaymentTransaction( + unsignedTransaction: UnsignedPaymentTransaction +) { + const transactionBytes = base64ToUint8Array( + unsignedTransaction.transactionBase64 + ); + + if ( + unsignedTransaction.version === undefined || + unsignedTransaction.version === null + ) { + try { + return Transaction.from(transactionBytes); + } catch { + return VersionedTransaction.deserialize(transactionBytes); + } + } + + if (unsignedTransaction.version === "legacy") { + return Transaction.from(transactionBytes); + } + + if ( + unsignedTransaction.version === "v0" || + unsignedTransaction.version === 0 || + unsignedTransaction.version === "0" + ) { + return VersionedTransaction.deserialize(transactionBytes); + } + + throw new Error( + `Unsupported transaction version: ${unsignedTransaction.version}` + ); +} + +export function serializeSignedPaymentTransaction( + transaction: Transaction | VersionedTransaction +) { + return uint8ArrayToBase64(transaction.serialize()); +} + +export async function submitSignedPaymentTransaction( + unsignedTransaction: UnsignedPaymentTransaction, + signedTransaction: Transaction | VersionedTransaction, + authToken?: string | null +) { + const headers: Record = { + "Content-Type": "application/json", + }; + + if (authToken) { + headers.Authorization = `Bearer ${authToken}`; + } + + const res = await fetch("/api/payments/send", { + method: "POST", + headers, + body: JSON.stringify({ + signedTransaction: serializeSignedPaymentTransaction(signedTransaction), + blockhash: unsignedTransaction.recentBlockhash, + lastValidBlockHeight: unsignedTransaction.lastValidBlockHeight, + sendTo: unsignedTransaction.sendTo, + }), + }); + + const body = await res.json().catch(() => null); + if (!res.ok) { + throw new PaymentTransactionSubmissionError( + body?.error || `Transaction submission failed: ${res.status}`, + { + status: res.status, + signature: + typeof body?.signature === "string" ? body.signature : undefined, + details: typeof body?.details === "string" ? body.details : undefined, + } + ); + } + + if (typeof body?.signature !== "string") { + throw new Error("Transaction submission did not return a signature"); + } + + return body.signature as string; +} diff --git a/lib/payments.ts b/lib/payments.ts index 638747b..cba7a7d 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -59,6 +59,8 @@ export const PAYMENTS_ENDPOINTS = { isMintInitialized: "/v1/spl/is-mint-initialized", splTransfer: "/v1/spl/transfer", transactionSend: "/v1/transaction/send", + splTransferStealth: "/v1/spl/transfer-stealth", + stealthPool: "/v1/spl/stealth-pool", swapQuote: "/v1/swap/quote", swap: "/v1/swap/swap", } as const; diff --git a/lib/solana-rpc.ts b/lib/solana-rpc.ts index 3658107..8909c08 100644 --- a/lib/solana-rpc.ts +++ b/lib/solana-rpc.ts @@ -10,6 +10,12 @@ const configuredSwapServerRpcEndpoint = process.env.SWAP_SOLANA_RPC_URL?.trim() ?? process.env.JUP_SWAP_SOLANA_RPC_URL?.trim() ?? ""; +const configuredPaymentsEphemeralRpcEndpoint = + process.env.PAYMENTS_EPHEMERAL_RPC_URL?.trim() ?? + process.env.EPHEMERAL_RPC_URL?.trim() ?? + process.env.NEXT_PUBLIC_PAYMENTS_EPHEMERAL_RPC_URL?.trim() ?? + process.env.NEXT_PUBLIC_EPHEMERAL_RPC_URL?.trim() ?? + ""; export function createServerSolanaConnection() { return new Connection(SOLANA_SERVER_RPC_ENDPOINT, "confirmed"); @@ -22,4 +28,19 @@ export function createSwapServerSolanaConnection() { ); } +export function createPaymentsEphemeralConnection(authToken?: string) { + if (!configuredPaymentsEphemeralRpcEndpoint) { + throw new Error("Missing PAYMENTS_EPHEMERAL_RPC_URL or EPHEMERAL_RPC_URL"); + } + + if (!authToken) { + return new Connection(configuredPaymentsEphemeralRpcEndpoint, "confirmed"); + } + + const endpoint = new URL(configuredPaymentsEphemeralRpcEndpoint); + endpoint.searchParams.set("token", authToken); + + return new Connection(endpoint.toString(), "confirmed"); +} + export { SOLANA_PUBLIC_RPC_ENDPOINT, SOLANA_SERVER_RPC_ENDPOINT }; diff --git a/lib/stealth-handles.ts b/lib/stealth-handles.ts new file mode 100644 index 0000000..efce6b8 --- /dev/null +++ b/lib/stealth-handles.ts @@ -0,0 +1,27 @@ +export const STEALTH_HANDLE_SUFFIX = ".block"; +export const STEALTH_POOL_MAX_DESTINATIONS = 10; + +const STORAGE_PREFIX = "magicblock:stealth-handle"; +const STEALTH_HANDLE_PATTERN = /^[a-z0-9][a-z0-9._-]*\.block$/; + +export function getExactStealthHandleInput(value: string) { + return value.trim(); +} + +export function isStealthHandleInput(value: string) { + return STEALTH_HANDLE_PATTERN.test(value); +} + +export function getStoredStealthHandle(owner: string): string | null { + if (typeof window === "undefined") return null; + + try { + return localStorage.getItem(`${STORAGE_PREFIX}:${owner}`); + } catch { + return null; + } +} + +export function setStoredStealthHandle(owner: string, handle: string) { + localStorage.setItem(`${STORAGE_PREFIX}:${owner}`, handle); +}