diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..957cd15 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals"] +} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0522910..b5b11bc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,6 +18,12 @@ jobs: - name: Install dependencies run: npm ci + - name: Lint + run: npm run lint + + - name: Test + run: npm test + - name: Build run: npm run build env: diff --git a/.gitignore b/.gitignore index e0b4074..9a3154c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,46 @@ +# Dependencies node_modules/ + +# Next.js build output .next/ out/ + +# Environment variables .env .env.local .env.*.local + +# TypeScript build info *.tsbuildinfo + +# OS files .DS_Store Thumbs.db + +# Test artifacts +coverage/ +__snapshots__/ +**/__snapshots__/ +*.snap + +# Editor / IDE +.vscode/ +.idea/ +*.swp +*.swo +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +*.log + +# Misc +.vercel +.eslintcache diff --git a/package.json b/package.json index 5d7a057..dd873a8 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "node --require ./node_modules/sucrase/dist/register.js --test 'src/__tests__/**/*.test.ts'" }, "dependencies": { "@stellar-split/sdk": "^0.1.0", diff --git a/src/__tests__/bridge.test.ts b/src/__tests__/bridge.test.ts new file mode 100644 index 0000000..3c91eb1 --- /dev/null +++ b/src/__tests__/bridge.test.ts @@ -0,0 +1,199 @@ +/** + * Unit tests for bridge fee estimation and chain selector logic. + * + * Run with: + * npm test + * + * Uses Node.js built-in test runner (node:test) — no additional dependencies + * required. Sucrase transpiles TypeScript at runtime. + */ +const { test, describe } = require("node:test") as typeof import("node:test"); +const assert = require("node:assert") as typeof import("node:assert"); + +const { + SUPPORTED_CHAINS, + estimateBridgeFee, +} = require("../lib/bridge.ts") as typeof import("../lib/bridge"); + +// ─── SUPPORTED_CHAINS ──────────────────────────────────────────────────────── + +describe("SUPPORTED_CHAINS", () => { + test("contains exactly two chains", () => { + assert.strictEqual(SUPPORTED_CHAINS.length, 2); + }); + + test("includes ethereum chain", () => { + const eth = SUPPORTED_CHAINS.find( + (c: { id: string }) => c.id === "ethereum" + ); + assert.ok(eth, "ethereum chain not found"); + assert.strictEqual(eth.label, "Ethereum Mainnet"); + assert.strictEqual(eth.currency, "ETH"); + assert.strictEqual(eth.walletName, "MetaMask"); + assert.ok(eth.estimatedTime.length > 0); + }); + + test("includes solana chain", () => { + const sol = SUPPORTED_CHAINS.find( + (c: { id: string }) => c.id === "solana" + ); + assert.ok(sol, "solana chain not found"); + assert.strictEqual(sol.label, "Solana Mainnet"); + assert.strictEqual(sol.currency, "SOL"); + assert.strictEqual(sol.walletName, "Phantom"); + assert.ok(sol.estimatedTime.length > 0); + }); + + test("each chain has all required fields", () => { + const requiredFields = ["id", "label", "currency", "walletName", "estimatedTime"]; + for (const chain of SUPPORTED_CHAINS) { + for (const field of requiredFields) { + assert.ok( + Object.prototype.hasOwnProperty.call(chain, field), + `Chain ${chain.id} missing field: ${field}` + ); + } + } + }); + + test("chain ids are unique", () => { + const ids = SUPPORTED_CHAINS.map((c: { id: string }) => c.id); + const unique = new Set(ids); + assert.strictEqual(unique.size, ids.length, "Duplicate chain IDs found"); + }); +}); + +// ─── estimateBridgeFee — Ethereum ───────────────────────────────────────────── + +describe("estimateBridgeFee — ethereum", () => { + test("returns correct structure", () => { + const result = estimateBridgeFee("ethereum", "100"); + assert.ok(typeof result.bridgeFee === "string"); + assert.ok(typeof result.bridgeFeeRatio === "number"); + assert.ok(typeof result.netAmount === "string"); + assert.ok(typeof result.estimatedTime === "string"); + }); + + test("fee ratio is 0.3 % for ethereum", () => { + const result = estimateBridgeFee("ethereum", "100"); + assert.strictEqual(result.bridgeFeeRatio, 0.003); + }); + + test("net amount is amount minus fee for ethereum", () => { + const result = estimateBridgeFee("ethereum", "100"); + // 100 - 100 * 0.003 = 99.7 + assert.strictEqual(result.netAmount, "99.700000"); + }); + + test("net amount is correct for fractional input", () => { + const result = estimateBridgeFee("ethereum", "50.5"); + // 50.5 - 50.5 * 0.003 = 50.3485 + const expected = (50.5 - 50.5 * 0.003).toFixed(6); + assert.strictEqual(result.netAmount, expected); + }); + + test("bridgeFee string contains ETH", () => { + const result = estimateBridgeFee("ethereum", "100"); + assert.ok(result.bridgeFee.includes("ETH"), `expected ETH in: ${result.bridgeFee}`); + }); + + test("estimatedTime is non-empty string", () => { + const result = estimateBridgeFee("ethereum", "100"); + assert.ok(result.estimatedTime.trim().length > 0); + }); + + test("handles large amount", () => { + const result = estimateBridgeFee("ethereum", "1000000"); + const expected = (1_000_000 - 1_000_000 * 0.003).toFixed(6); + assert.strictEqual(result.netAmount, expected); + }); + + test("handles zero amount — net is 0", () => { + const result = estimateBridgeFee("ethereum", "0"); + assert.strictEqual(parseFloat(result.netAmount), 0); + }); + + test("handles empty string amount — net is 0", () => { + const result = estimateBridgeFee("ethereum", ""); + assert.strictEqual(parseFloat(result.netAmount), 0); + }); + + test("net amount never goes negative", () => { + const result = estimateBridgeFee("ethereum", "-50"); + assert.ok(parseFloat(result.netAmount) >= 0); + }); +}); + +// ─── estimateBridgeFee — Solana ─────────────────────────────────────────────── + +describe("estimateBridgeFee — solana", () => { + test("fee ratio is 0.1 % for solana", () => { + const result = estimateBridgeFee("solana", "100"); + assert.strictEqual(result.bridgeFeeRatio, 0.001); + }); + + test("net amount is amount minus fee for solana", () => { + const result = estimateBridgeFee("solana", "100"); + // 100 - 100 * 0.001 = 99.9 + assert.strictEqual(result.netAmount, "99.900000"); + }); + + test("bridgeFee string contains SOL", () => { + const result = estimateBridgeFee("solana", "100"); + assert.ok(result.bridgeFee.includes("SOL"), `expected SOL in: ${result.bridgeFee}`); + }); + + test("solana fee is lower than ethereum fee for same amount", () => { + const eth = estimateBridgeFee("ethereum", "100"); + const sol = estimateBridgeFee("solana", "100"); + assert.ok( + parseFloat(sol.netAmount) > parseFloat(eth.netAmount), + `solana net ${sol.netAmount} should be higher than ethereum net ${eth.netAmount}` + ); + }); + + test("handles fractional input", () => { + const result = estimateBridgeFee("solana", "200.25"); + const expected = (200.25 - 200.25 * 0.001).toFixed(6); + assert.strictEqual(result.netAmount, expected); + }); + + test("estimatedTime is non-empty string", () => { + const result = estimateBridgeFee("solana", "100"); + assert.ok(result.estimatedTime.trim().length > 0); + }); +}); + +// ─── estimateBridgeFee — cross-chain comparison ─────────────────────────────── + +describe("estimateBridgeFee — cross-chain", () => { + test("both chains return matching structure shape", () => { + const eth = estimateBridgeFee("ethereum", "100"); + const sol = estimateBridgeFee("solana", "100"); + const keys = ["bridgeFee", "bridgeFeeRatio", "netAmount", "estimatedTime"]; + for (const key of keys) { + assert.ok(key in eth, `ethereum result missing key: ${key}`); + assert.ok(key in sol, `solana result missing key: ${key}`); + } + }); + + test("estimatedTime differs between chains", () => { + const eth = estimateBridgeFee("ethereum", "100"); + const sol = estimateBridgeFee("solana", "100"); + assert.notStrictEqual( + eth.estimatedTime, + sol.estimatedTime, + "Estimated time should differ between chains" + ); + }); + + test("net amount is a valid decimal string", () => { + for (const chain of ["ethereum", "solana"] as const) { + const result = estimateBridgeFee(chain, "123.456"); + assert.ok( + /^\d+\.\d+$/.test(result.netAmount), + `Expected decimal string, got: ${result.netAmount}` + ); + } + }); +}); diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index 325594e..5df1510 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -5,6 +5,7 @@ import { splitClient } from "@/lib/stellar"; import { getFreighterPublicKey } from "@/lib/freighter"; import { formatAmount, parseAmount } from "@stellar-split/sdk"; import PaymentProgress from "@/components/PaymentProgress"; +import CrossChainPayment from "@/components/CrossChainPayment"; import type { Invoice } from "@stellar-split/sdk"; interface Props { @@ -12,7 +13,9 @@ interface Props { } /** - * Invoice detail page — shows status, payment progress, and a Pay button. + * Invoice detail page — shows status, payment progress, and payment options: + * 1. Pay with Freighter (native Stellar) + * 2. Pay from Another Chain (cross-chain bridge via Ethereum / Solana) */ export default function InvoiceDetailPage({ params }: Props) { const { id } = params; @@ -31,6 +34,7 @@ export default function InvoiceDetailPage({ params }: Props) { useEffect(() => { load().catch((e) => setError(String(e))); getFreighterPublicKey().then(setPublicKey).catch(() => null); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); const total = invoice @@ -79,6 +83,14 @@ export default function InvoiceDetailPage({ params }: Props) { Refunded: "bg-gray-500", }; + /** + * The Stellar destination for cross-chain payments is the contract ID. + * The StellarSplitClient is initialised with NEXT_PUBLIC_CONTRACT_ID, so we + * read it from the environment the same way stellar.ts does. + */ + const stellarDestination = + process.env.NEXT_PUBLIC_CONTRACT_ID ?? invoice.token; + return (
@@ -116,39 +128,58 @@ export default function InvoiceDetailPage({ params }: Props) { - {/* Pay form */} - {invoice.status === "Pending" && publicKey && ( -
-

Pay toward this invoice

- setPayAmount(e.target.value)} - required - className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" - /> - {error &&

{error}

} - {txHash && ( -

- Payment sent! Tx: {txHash.slice(0, 12)}… -

+ {invoice.status === "Pending" && ( +
+ {/* ── Option 1: Pay with Freighter ──────────────────────────── */} + {publicKey && ( + +

Pay with Freighter

+ setPayAmount(e.target.value)} + required + aria-label="Amount in USDC" + className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + {error &&

{error}

} + {txHash && ( +

+ Payment sent! Tx: {txHash.slice(0, 12)}… +

+ )} + + )} - - + + {/* ── Divider ───────────────────────────────────────────────── */} +
+
+ or +
+
+ + {/* ── Option 2: Pay from Another Chain ──────────────────────── */} + +
)} {invoice.status !== "Pending" && (

- This invoice is {invoice.status.toLowerCase()} and no longer accepts payments. + This invoice is {invoice.status.toLowerCase()} and no longer accepts + payments.

)}
diff --git a/src/components/CrossChainPayment.tsx b/src/components/CrossChainPayment.tsx new file mode 100644 index 0000000..a83fac0 --- /dev/null +++ b/src/components/CrossChainPayment.tsx @@ -0,0 +1,490 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + SUPPORTED_CHAINS, + estimateBridgeFee, + connectWalletForChain, + buildBridgePayment, + pollBridgeStatus, +} from "@/lib/bridge"; +import type { + SupportedChain, + FeeEstimate, + BridgeStatusResult, +} from "@/lib/bridge"; + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +/** Chain selector tabs. */ +function ChainSelector({ + selected, + onSelect, + disabled, +}: { + selected: SupportedChain; + onSelect: (chain: SupportedChain) => void; + disabled: boolean; +}) { + return ( +
+ {SUPPORTED_CHAINS.map((chain) => ( + + ))} +
+ ); +} + +/** Fee breakdown table shown after chain selection + amount entry. */ +function FeeBreakdown({ + amount, + estimate, +}: { + amount: string; + estimate: FeeEstimate | null; +}) { + if (!amount || parseFloat(amount) <= 0 || !estimate) return null; + + return ( +
+
+
You send
+
{parseFloat(amount).toFixed(6)} USDC
+
+
+
Bridge fee
+
{estimate.bridgeFee}
+
+
+
Net received on Stellar
+
{estimate.netAmount} USDC
+
+
+
Estimated time
+
{estimate.estimatedTime}
+
+
+ ); +} + +/** Wallet connect step. */ +function WalletStep({ + chain, + address, + onConnect, + connecting, + error, +}: { + chain: SupportedChain; + address: string | null; + onConnect: () => void; + connecting: boolean; + error: string | null; +}) { + const meta = SUPPORTED_CHAINS.find((c) => c.id === chain)!; + + if (address) { + return ( +
+ + + {meta.walletName} connected:{" "} + + {address.slice(0, 8)}…{address.slice(-6)} + + +
+ ); + } + + return ( +
+ + {error &&

{error}

} +
+ ); +} + +/** Progress stepper shown while polling bridge status. */ +const STATUS_STEPS: Array<{ key: BridgeStatusResult["status"]; label: string }> = [ + { key: "pending", label: "Source chain confirmed" }, + { key: "in_transit", label: "Bridge relay in transit" }, + { key: "relaying", label: "Relaying to Stellar" }, + { key: "confirmed", label: "Stellar relay confirmed" }, +]; + +const STATUS_ORDER: BridgeStatusResult["status"][] = [ + "pending", + "in_transit", + "relaying", + "confirmed", +]; + +function BridgeProgress({ + statusResult, + sourceTxHash, +}: { + statusResult: BridgeStatusResult; + sourceTxHash: string; +}) { + const currentIdx = STATUS_ORDER.indexOf(statusResult.status); + + return ( +
+ {/* Source tx hash */} +
+

Source transaction

+

{sourceTxHash}

+
+ + {/* Steps */} +
    + {STATUS_STEPS.map((step, idx) => { + const done = idx <= currentIdx; + const active = idx === currentIdx && statusResult.status !== "confirmed"; + return ( +
  1. + + {step.label} + {active && ( + + In progress… + + )} +
  2. + ); + })} +
+ + {/* Stellar tx hash on confirmed */} + {statusResult.stellarTxHash && ( +
+

✓ Confirmed on Stellar

+

+ {statusResult.stellarTxHash} +

+
+ )} + + {/* Failed state */} + {statusResult.status === "failed" && ( +

{statusResult.message}

+ )} +
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +interface Props { + /** Invoice ID used as the reference in the bridge payment. */ + invoiceId: string; + /** Stellar contract / escrow address that receives the bridged funds. */ + stellarDestination: string; +} + +type Step = "select" | "wallet" | "submit" | "polling" | "done"; + +/** + * CrossChainPayment — full cross-chain payment flow from Ethereum or Solana. + * + * Flow: + * 1. Select source chain + * 2. Enter USDC amount → see live fee estimate + * 3. Connect MetaMask / Phantom + * 4. Submit bridge payment + * 5. Poll status until Stellar relay confirmed + */ +export default function CrossChainPayment({ invoiceId, stellarDestination }: Props) { + const [open, setOpen] = useState(false); + const [step, setStep] = useState("select"); + + // Chain & amount + const [chain, setChain] = useState("ethereum"); + const [amount, setAmount] = useState(""); + const [estimate, setEstimate] = useState(null); + + // Wallet + const [walletAddress, setWalletAddress] = useState(null); + const [walletConnecting, setWalletConnecting] = useState(false); + const [walletError, setWalletError] = useState(null); + + // Submission + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [sourceTxHash, setSourceTxHash] = useState(""); + const [bridgeId, setBridgeId] = useState(""); + + // Polling + const [statusResult, setStatusResult] = useState(null); + + // Recalculate fee estimate whenever chain or amount changes + useEffect(() => { + if (amount && parseFloat(amount) > 0) { + setEstimate(estimateBridgeFee(chain, amount)); + } else { + setEstimate(null); + } + }, [chain, amount]); + + // Reset wallet when chain changes + useEffect(() => { + setWalletAddress(null); + setWalletError(null); + }, [chain]); + + const handleChainSelect = (c: SupportedChain) => { + setChain(c); + setStep("select"); + }; + + const handleConnectWallet = async () => { + setWalletConnecting(true); + setWalletError(null); + try { + const addr = await connectWalletForChain(chain); + setWalletAddress(addr); + setStep("submit"); + } catch (e) { + setWalletError(e instanceof Error ? e.message : String(e)); + } finally { + setWalletConnecting(false); + } + }; + + const handleProceedToWallet = () => { + if (!amount || parseFloat(amount) <= 0) return; + setStep("wallet"); + }; + + const handleSubmit = useCallback(async () => { + if (!walletAddress) return; + setSubmitting(true); + setSubmitError(null); + try { + const result = await buildBridgePayment({ + chain, + fromAddress: walletAddress, + invoiceId, + amount, + stellarDestination, + }); + setSourceTxHash(result.sourceTxHash); + setBridgeId(result.bridgeId); + setStep("polling"); + + await pollBridgeStatus(result.bridgeId, (s) => { + setStatusResult(s); + if (s.status === "confirmed") setStep("done"); + }); + } catch (e) { + setSubmitError(e instanceof Error ? e.message : String(e)); + } finally { + setSubmitting(false); + } + }, [walletAddress, chain, invoiceId, amount, stellarDestination]); + + const handleReset = () => { + setStep("select"); + setAmount(""); + setEstimate(null); + setWalletAddress(null); + setWalletError(null); + setSubmitError(null); + setSourceTxHash(""); + setBridgeId(""); + setStatusResult(null); + setOpen(false); + }; + + if (!open) { + return ( + + ); + } + + return ( +
+
+

Pay from Another Chain

+ {step !== "polling" && ( + + )} +
+ + {/* ── Step 1 & 2: Chain + Amount ─────────────────────────────────────── */} + {(step === "select" || step === "wallet" || step === "submit") && ( + <> +
+

+ Source chain +

+ +
+ +
+ + { + setAmount(e.target.value); + if (step !== "select") setStep("select"); + }} + disabled={false} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50" + aria-label="Amount in USDC to send from source chain" + /> +
+ + {/* Fee estimate */} + + + )} + + {/* ── Step 2 → 3 proceed button ─────────────────────────────────────── */} + {step === "select" && amount && parseFloat(amount) > 0 && ( + + )} + + {/* ── Step 3: Wallet connect ────────────────────────────────────────── */} + {(step === "wallet" || step === "submit") && ( +
+

+ Connect wallet +

+ +
+ )} + + {/* ── Step 4: Submit ────────────────────────────────────────────────── */} + {step === "submit" && walletAddress && ( +
+ {submitError && ( +

{submitError}

+ )} + +
+ )} + + {/* ── Step 5: Polling progress ──────────────────────────────────────── */} + {(step === "polling" || step === "done") && statusResult && ( + + )} + + {/* ── Done: actions ────────────────────────────────────────────────── */} + {step === "done" && ( + + )} + + {/* Polling spinner / status message when not yet done */} + {step === "polling" && statusResult && statusResult.status !== "confirmed" && ( +

+ {statusResult.message} +

+ )} + + {/* bridgeId used only internally — suppress TS unused warning */} + {bridgeId && false && {bridgeId}} +
+ ); +} diff --git a/src/lib/bridge.ts b/src/lib/bridge.ts new file mode 100644 index 0000000..3f9882a --- /dev/null +++ b/src/lib/bridge.ts @@ -0,0 +1,289 @@ +/** + * Cross-chain bridge helpers for StellarSplit. + * + * Supports Ethereum Mainnet (via MetaMask) and Solana Mainnet (via Phantom). + * Bridge fee estimation, wallet connection, payment submission and status + * polling are all handled here so components stay thin. + * + * NOTE: In production these fee numbers come from a real bridge SDK (e.g. + * Wormhole Connect or Squid Router). Here they are deterministic mocks so + * the UI can be built and tested without live RPC access. + */ + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type SupportedChain = "ethereum" | "solana"; + +export interface ChainMeta { + id: SupportedChain; + label: string; + currency: string; + /** Wallet provider name shown in UI */ + walletName: string; + /** Estimated bridge time displayed to user */ + estimatedTime: string; +} + +export interface FeeEstimate { + /** Chain-native bridge fee (human-readable, e.g. "0.002 ETH") */ + bridgeFee: string; + /** Fee as a fraction of the invoice amount (0–1) */ + bridgeFeeRatio: number; + /** Amount that actually arrives on Stellar side, in USDC */ + netAmount: string; + /** Estimated settlement time */ + estimatedTime: string; +} + +export interface BridgePaymentRequest { + chain: SupportedChain; + /** Sender address on the source chain */ + fromAddress: string; + /** Stellar invoice ID */ + invoiceId: string; + /** Amount to send in USDC (human-readable) */ + amount: string; + /** Stellar destination (the StellarSplit contract or escrow) */ + stellarDestination: string; +} + +export interface BridgePaymentResult { + /** Transaction hash on the source chain */ + sourceTxHash: string; + /** VAA / relay ID used to track the bridge relay */ + bridgeId: string; +} + +export type BridgeStatus = + | "pending" // waiting for source-chain confirmation + | "in_transit" // bridge relay in progress + | "relaying" // Stellar-side relay transaction being submitted + | "confirmed" // Stellar-side relay confirmed + | "failed"; // bridge failed + +export interface BridgeStatusResult { + status: BridgeStatus; + /** Human-readable description */ + message: string; + /** Stellar-side transaction hash, present when status === "confirmed" */ + stellarTxHash?: string; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +export const SUPPORTED_CHAINS: ChainMeta[] = [ + { + id: "ethereum", + label: "Ethereum Mainnet", + currency: "ETH", + walletName: "MetaMask", + estimatedTime: "~10 min", + }, + { + id: "solana", + label: "Solana Mainnet", + currency: "SOL", + walletName: "Phantom", + estimatedTime: "~2 min", + }, +]; + +/** Fee ratios per chain (fraction of the USDC amount). */ +const BRIDGE_FEE_RATIOS: Record = { + ethereum: 0.003, // 0.3 % + solana: 0.001, // 0.1 % +}; + +/** Fixed gas/relay fee in the source-chain native token. */ +const NATIVE_FEES: Record = { + ethereum: "0.002 ETH", + solana: "0.000005 SOL", +}; + +// ─── Fee estimation ─────────────────────────────────────────────────────────── + +/** + * Estimate bridge fees for a given chain and USDC amount. + * + * @param chain - Source chain + * @param amount - USDC amount the user wants to send (human-readable) + */ +export function estimateBridgeFee( + chain: SupportedChain, + amount: string +): FeeEstimate { + const meta = SUPPORTED_CHAINS.find((c) => c.id === chain)!; + const parsed = parseFloat(amount) || 0; + const ratio = BRIDGE_FEE_RATIOS[chain]; + const fee = parsed * ratio; + const net = Math.max(0, parsed - fee); + + return { + bridgeFee: `${NATIVE_FEES[chain]} + ${(fee).toFixed(6)} USDC`, + bridgeFeeRatio: ratio, + netAmount: net.toFixed(6), + estimatedTime: meta.estimatedTime, + }; +} + +// ─── Wallet helpers ─────────────────────────────────────────────────────────── + +/** Ethereum / MetaMask -------------------------------------------------------*/ + +/** Type-safe accessor for window.ethereum (EIP-1193). */ +function getEthereum(): { request: (args: { method: string; params?: unknown[] }) => Promise } | null { + if (typeof window === "undefined") return null; + return (window as unknown as { ethereum?: { request: (args: { method: string; params?: unknown[] }) => Promise } }).ethereum ?? null; +} + +/** + * Connect MetaMask and return the user's Ethereum address. + * Throws if MetaMask is not installed. + */ +export async function connectMetaMask(): Promise { + const eth = getEthereum(); + if (!eth) throw new Error("MetaMask is not installed. Please install it from metamask.io."); + + const accounts = (await eth.request({ + method: "eth_requestAccounts", + })) as string[]; + + if (!accounts || accounts.length === 0) { + throw new Error("No accounts returned from MetaMask."); + } + return accounts[0]; +} + +/** Solana / Phantom ----------------------------------------------------------*/ + +interface PhantomProvider { + isPhantom?: boolean; + connect: () => Promise<{ publicKey: { toString(): string } }>; + disconnect: () => Promise; + signTransaction: (tx: unknown) => Promise; +} + +function getPhantom(): PhantomProvider | null { + if (typeof window === "undefined") return null; + const provider = (window as unknown as { solana?: PhantomProvider }).solana; + return provider?.isPhantom ? provider : null; +} + +/** + * Connect Phantom and return the user's Solana public key. + * Throws if Phantom is not installed. + */ +export async function connectPhantom(): Promise { + const phantom = getPhantom(); + if (!phantom) throw new Error("Phantom is not installed. Please install it from phantom.app."); + + const resp = await phantom.connect(); + return resp.publicKey.toString(); +} + +/** + * Connect the appropriate wallet for a given chain. + * + * @returns Connected wallet address on the source chain. + */ +export async function connectWalletForChain(chain: SupportedChain): Promise { + if (chain === "ethereum") return connectMetaMask(); + if (chain === "solana") return connectPhantom(); + throw new Error(`Unsupported chain: ${chain}`); +} + +// ─── Bridge payment ─────────────────────────────────────────────────────────── + +/** + * Build and submit a cross-chain bridge payment. + * + * In production this calls a bridge SDK (Wormhole, Squid, etc.). + * Here we produce a deterministic mock result so the UI flow works end-to-end + * without live RPC access. + */ +export async function buildBridgePayment( + req: BridgePaymentRequest +): Promise { + if (typeof window === "undefined") throw new Error("Browser only"); + + // Validate inputs + if (!req.fromAddress) throw new Error("Wallet not connected."); + if (!req.amount || parseFloat(req.amount) <= 0) throw new Error("Invalid amount."); + if (!req.stellarDestination) throw new Error("Stellar destination address required."); + + // Simulate network latency + await delay(800); + + // Build a deterministic mock transaction hash based on inputs + const seed = `${req.chain}:${req.fromAddress}:${req.invoiceId}:${req.amount}:${Date.now()}`; + const sourceTxHash = "0x" + hashString(seed).toString(16).padStart(64, "0"); + const bridgeId = "bridge_" + hashString(seed + "relay").toString(16).slice(0, 24); + + return { sourceTxHash, bridgeId }; +} + +// ─── Status polling ─────────────────────────────────────────────────────────── + +/** Status progression sequence used by the mock poller. */ +const STATUS_SEQUENCE: Array<{ status: BridgeStatus; message: string }> = [ + { status: "pending", message: "Waiting for source-chain confirmation…" }, + { status: "in_transit", message: "Transaction confirmed. Bridge relay in progress…" }, + { status: "relaying", message: "Relaying to Stellar network…" }, + { status: "confirmed", message: "Payment confirmed on Stellar! ✓" }, +]; + +/** + * Poll bridge status until confirmed or failed. + * + * Calls `onUpdate` on every status change. + * Resolves when status reaches "confirmed" or "failed". + * + * @param bridgeId - Bridge relay ID from buildBridgePayment + * @param onUpdate - Callback for each status update + * @param intervalMs - Polling interval in milliseconds (default 3 000) + */ +export async function pollBridgeStatus( + bridgeId: string, + onUpdate: (result: BridgeStatusResult) => void, + intervalMs = 3_000 +): Promise { + // Derive a mock Stellar tx hash from the bridge ID + const stellarTxHash = hashString(bridgeId + "stellar").toString(16).padStart(64, "0"); + + for (const step of STATUS_SEQUENCE) { + await delay(intervalMs); + const result: BridgeStatusResult = { + ...step, + ...(step.status === "confirmed" ? { stellarTxHash } : {}), + }; + onUpdate(result); + if (step.status === "confirmed" || step.status === "failed") { + return result; + } + } + + // Should not reach here, but satisfy TS + const final: BridgeStatusResult = { + status: "confirmed", + message: "Payment confirmed on Stellar! ✓", + stellarTxHash, + }; + onUpdate(final); + return final; +} + +// ─── Internal utilities ─────────────────────────────────────────────────────── + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Simple deterministic hash (djb2) — for mock tx hashes only. */ +function hashString(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash) ^ str.charCodeAt(i); + hash = hash >>> 0; // keep unsigned 32-bit + } + return hash; +}