From 83d7fcab7140d36c1bb9285673afea69059f94aa Mon Sep 17 00:00:00 2001 From: George Mathew <97821867+0xgeorgemathew@users.noreply.github.com> Date: Sun, 30 Nov 2025 07:30:59 +0530 Subject: [PATCH 1/7] UI Colors and Minor Refactoring. --- .../foundry/contracts/SplitHubPayments.sol | 49 +-- .../foundry/contracts/SplitHubRegistry.sol | 25 +- .../script/DeploySplitHubPayments.s.sol | 2 +- packages/foundry/test/SplitHubPayments.t.sol | 7 +- packages/foundry/test/SplitHubRegistry.t.sol | 4 +- packages/nextjs/app/approve/page.tsx | 289 +++++++------- packages/nextjs/app/defi/page.tsx | 11 - packages/nextjs/app/multi-sig/page.tsx | 11 - packages/nextjs/app/page.tsx | 56 ++- packages/nextjs/app/pay/page.tsx | 11 - packages/nextjs/app/re-register/page.tsx | 330 +++++++++------- packages/nextjs/app/settle/page.tsx | 354 ++++++++++-------- packages/nextjs/components/BottomNav.tsx | 43 +++ .../ScaffoldEthAppWithProviders.tsx | 10 +- packages/nextjs/components/TopNav.tsx | 194 ++++++++++ packages/nextjs/styles/globals.css | 230 ++++++++++-- 16 files changed, 1039 insertions(+), 587 deletions(-) delete mode 100644 packages/nextjs/app/defi/page.tsx delete mode 100644 packages/nextjs/app/multi-sig/page.tsx delete mode 100644 packages/nextjs/app/pay/page.tsx create mode 100644 packages/nextjs/components/BottomNav.tsx create mode 100644 packages/nextjs/components/TopNav.tsx diff --git a/packages/foundry/contracts/SplitHubPayments.sol b/packages/foundry/contracts/SplitHubPayments.sol index 1a5cd96..ff106e8 100644 --- a/packages/foundry/contracts/SplitHubPayments.sol +++ b/packages/foundry/contracts/SplitHubPayments.sol @@ -15,12 +15,12 @@ contract SplitHubPayments is EIP712 { using SafeERC20 for IERC20; struct PaymentAuth { - address payer; // Token owner (funds come from here) - address recipient; // Payment receiver - address token; // ERC20 token address - uint256 amount; // Amount to transfer - uint256 nonce; // Replay protection (auto-increment per payer) - uint256 deadline; // Signature expiration timestamp + address payer; // Token owner (funds come from here) + address recipient; // Payment receiver + address token; // ERC20 token address + uint256 amount; // Amount to transfer + uint256 nonce; // Replay protection (auto-increment per payer) + uint256 deadline; // Signature expiration timestamp } bytes32 public constant PAYMENT_AUTH_TYPEHASH = keccak256( @@ -68,15 +68,11 @@ contract SplitHubPayments is EIP712 { nonces[auth.payer]++; // Verify signature - bytes32 structHash = keccak256(abi.encode( - PAYMENT_AUTH_TYPEHASH, - auth.payer, - auth.recipient, - auth.token, - auth.amount, - auth.nonce, - auth.deadline - )); + bytes32 structHash = keccak256( + abi.encode( + PAYMENT_AUTH_TYPEHASH, auth.payer, auth.recipient, auth.token, auth.amount, auth.nonce, auth.deadline + ) + ); bytes32 digest = _hashTypedDataV4(structHash); address signer = digest.recover(signature); @@ -88,14 +84,7 @@ contract SplitHubPayments is EIP712 { // Transfer tokens from payer to recipient IERC20(auth.token).safeTransferFrom(auth.payer, auth.recipient, auth.amount); - emit PaymentExecuted( - auth.payer, - auth.recipient, - auth.token, - auth.amount, - signer, - auth.nonce - ); + emit PaymentExecuted(auth.payer, auth.recipient, auth.token, auth.amount, signer, auth.nonce); } /// @notice Get the current nonce for a payer @@ -115,15 +104,11 @@ contract SplitHubPayments is EIP712 { /// @param auth The payment authorization to sign /// @return The EIP-712 typed data hash to sign function getDigest(PaymentAuth calldata auth) external view returns (bytes32) { - bytes32 structHash = keccak256(abi.encode( - PAYMENT_AUTH_TYPEHASH, - auth.payer, - auth.recipient, - auth.token, - auth.amount, - auth.nonce, - auth.deadline - )); + bytes32 structHash = keccak256( + abi.encode( + PAYMENT_AUTH_TYPEHASH, auth.payer, auth.recipient, auth.token, auth.amount, auth.nonce, auth.deadline + ) + ); return _hashTypedDataV4(structHash); } } diff --git a/packages/foundry/contracts/SplitHubRegistry.sol b/packages/foundry/contracts/SplitHubRegistry.sol index e864c42..6d5fdb0 100644 --- a/packages/foundry/contracts/SplitHubRegistry.sol +++ b/packages/foundry/contracts/SplitHubRegistry.sol @@ -10,9 +10,8 @@ import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; contract SplitHubRegistry is EIP712 { using ECDSA for bytes32; - bytes32 public constant CHIP_REGISTRATION_TYPEHASH = keccak256( - "ChipRegistration(address owner,address chipAddress)" - ); + bytes32 public constant CHIP_REGISTRATION_TYPEHASH = + keccak256("ChipRegistration(address owner,address chipAddress)"); mapping(address signer => address owner) public ownerOf; mapping(address owner => address signer) public signerOf; @@ -21,7 +20,7 @@ contract SplitHubRegistry is EIP712 { error InvalidSignature(); - constructor() EIP712("SplitHubRegistry", "1") {} + constructor() EIP712("SplitHubRegistry", "1") { } /// @notice Register a signer address to an owner (gasless - can be called by relayer) /// @param signer The address being registered (e.g., NFC chip address) @@ -29,11 +28,13 @@ contract SplitHubRegistry is EIP712 { /// @param signature EIP-712 signature of ChipRegistration struct by signer function register(address signer, address owner, bytes calldata signature) external { // Verify EIP-712 signature - bytes32 structHash = keccak256(abi.encode( - CHIP_REGISTRATION_TYPEHASH, - owner, - signer // chipAddress in the struct - )); + bytes32 structHash = keccak256( + abi.encode( + CHIP_REGISTRATION_TYPEHASH, + owner, + signer // chipAddress in the struct + ) + ); bytes32 digest = _hashTypedDataV4(structHash); address recovered = digest.recover(signature); @@ -58,11 +59,7 @@ contract SplitHubRegistry is EIP712 { /// @param chipAddress The chip address /// @return The EIP-712 typed data hash to sign function getDigest(address owner, address chipAddress) external view returns (bytes32) { - bytes32 structHash = keccak256(abi.encode( - CHIP_REGISTRATION_TYPEHASH, - owner, - chipAddress - )); + bytes32 structHash = keccak256(abi.encode(CHIP_REGISTRATION_TYPEHASH, owner, chipAddress)); return _hashTypedDataV4(structHash); } } diff --git a/packages/foundry/script/DeploySplitHubPayments.s.sol b/packages/foundry/script/DeploySplitHubPayments.s.sol index 1e1eecd..97dc1bc 100644 --- a/packages/foundry/script/DeploySplitHubPayments.s.sol +++ b/packages/foundry/script/DeploySplitHubPayments.s.sol @@ -52,7 +52,7 @@ contract DeploySplitHubPayments is ScaffoldETHDeploy { return vm.parseAddress(keys[i]); } } - } catch {} + } catch { } revert RegistryNotDeployed(); } diff --git a/packages/foundry/test/SplitHubPayments.t.sol b/packages/foundry/test/SplitHubPayments.t.sol index 230b6c0..0687b82 100644 --- a/packages/foundry/test/SplitHubPayments.t.sol +++ b/packages/foundry/test/SplitHubPayments.t.sol @@ -83,12 +83,7 @@ contract SplitHubPaymentsTest is Test { uint256 nonce = payments.getNonce(PAYER); SplitHubPayments.PaymentAuth memory auth = SplitHubPayments.PaymentAuth({ - payer: PAYER, - recipient: RECIPIENT, - token: address(token), - amount: amount, - nonce: nonce, - deadline: deadline + payer: PAYER, recipient: RECIPIENT, token: address(token), amount: amount, nonce: nonce, deadline: deadline }); // Sign the payment auth diff --git a/packages/foundry/test/SplitHubRegistry.t.sol b/packages/foundry/test/SplitHubRegistry.t.sol index ebfaa4a..17d482c 100644 --- a/packages/foundry/test/SplitHubRegistry.t.sol +++ b/packages/foundry/test/SplitHubRegistry.t.sol @@ -8,8 +8,8 @@ contract SplitHubRegistryTest is Test { SplitHubRegistry public registry; // Real keystore addresses - address constant DEPLOYER = 0xB2b6B516Df4B159c0E4Eb1d6D7D65a5f2F04C30E; // Also the owner - address constant BRAVO_KEY = 0x59d4C5BE20B41139494b3F1ba2A745ad9e71B00B; // NFC chip (signer) + address constant DEPLOYER = 0xB2b6B516Df4B159c0E4Eb1d6D7D65a5f2F04C30E; // Also the owner + address constant BRAVO_KEY = 0x59d4C5BE20B41139494b3F1ba2A745ad9e71B00B; // NFC chip (signer) // Loaded from environment uint256 bravoKeyPk; diff --git a/packages/nextjs/app/approve/page.tsx b/packages/nextjs/app/approve/page.tsx index a70e872..f8ae6bf 100644 --- a/packages/nextjs/app/approve/page.tsx +++ b/packages/nextjs/app/approve/page.tsx @@ -1,12 +1,16 @@ "use client"; import { useState } from "react"; -import { AlertCircle, CheckCircle2, Coins, Loader2, Shield, Wallet } from "lucide-react"; -import { isAddress, maxUint256, parseUnits } from "viem"; +import { AlertCircle, Check, Coins, Loader2, Shield, Wallet } from "lucide-react"; +import { parseUnits } from "viem"; import { useAccount, useReadContract, useWaitForTransactionReceipt, useWriteContract } from "wagmi"; import deployedContracts from "~~/contracts/deployedContracts"; import { useTargetNetwork } from "~~/hooks/scaffold-eth"; +// Default values +const DEFAULT_TOKEN_ADDRESS = "0x0a215D8ba66387DCA84B284D18c3B4ec3de6E54a" as const; +const DEFAULT_AMOUNT = "1000"; + const ERC20_ABI = [ { name: "approve", @@ -38,9 +42,6 @@ export default function ApprovePage() { const { address, isConnected } = useAccount(); const { targetNetwork } = useTargetNetwork(); - const [tokenAddress, setTokenAddress] = useState(""); - const [amount, setAmount] = useState(""); - const [approveUnlimited, setApproveUnlimited] = useState(false); const [error, setError] = useState(""); // Get SplitHubPayments contract address for the current network @@ -51,22 +52,16 @@ export default function ApprovePage() { // Read token decimals const { data: decimals } = useReadContract({ - address: isAddress(tokenAddress) ? (tokenAddress as `0x${string}`) : undefined, + address: DEFAULT_TOKEN_ADDRESS, abi: ERC20_ABI, functionName: "decimals", - query: { - enabled: isAddress(tokenAddress), - }, }); // Read token symbol const { data: symbol } = useReadContract({ - address: isAddress(tokenAddress) ? (tokenAddress as `0x${string}`) : undefined, + address: DEFAULT_TOKEN_ADDRESS, abi: ERC20_ABI, functionName: "symbol", - query: { - enabled: isAddress(tokenAddress), - }, }); // Write contract hook @@ -85,31 +80,21 @@ export default function ApprovePage() { return; } - if (!isAddress(tokenAddress)) { - setError("Please enter a valid token address"); - return; - } - if (!spenderAddress) { setError("SplitHubPayments contract not deployed on this network"); return; } - if (!approveUnlimited && !amount) { - setError("Please enter an amount or select unlimited approval"); - return; - } - if (decimals === undefined) { - setError("Could not read token decimals. Is this a valid ERC-20 token?"); + setError("Could not read token decimals"); return; } try { - const approvalAmount = approveUnlimited ? maxUint256 : parseUnits(amount, decimals); + const approvalAmount = parseUnits(DEFAULT_AMOUNT, decimals); writeContract({ - address: tokenAddress as `0x${string}`, + address: DEFAULT_TOKEN_ADDRESS, abi: ERC20_ABI, functionName: "approve", args: [spenderAddress, approvalAmount], @@ -122,153 +107,137 @@ export default function ApprovePage() { const handleReset = () => { reset(); - setTokenAddress(""); - setAmount(""); - setApproveUnlimited(false); setError(""); }; return ( -
-
- {/* Header */} -
-
- +
+
+ {!isConnected ? ( + /* Not Connected State */ +
+
+ +
+

Connect your wallet to approve

-

Token Approval

-

Approve SplitHubPayments to spend your tokens

-
+ ) : isSuccess ? ( + /* Success State */ +
+
+ +
+

Approved!

+ + {/* Approval info */} +
+ + + {DEFAULT_AMOUNT} {symbol || "tokens"} + +
+ + {/* Transaction hash */} + {txHash && ( + + View transaction → + + )} - {/* Main Card */} -
-
- {!isConnected ? ( - /* Not Connected State */ -
-
- -
-

Connect Your Wallet

-

Please connect your wallet using the button in the header to continue

+ +
+ ) : isPending || isConfirming ? ( + /* Processing State */ +
+ {/* Progress indicator */} +
+
+ {isPending ? "1" : }
- ) : isSuccess ? ( - /* Success State */ -
-
- -
-

Approval Successful!

-

{symbol || "Token"} has been approved for SplitHubPayments

- {txHash && ( -

- Tx: {txHash.slice(0, 10)}...{txHash.slice(-8)} -

- )} - +
+
+ 2
- ) : ( - /* Form */ -
- {/* Spender Info */} -
-

Spender (SplitHubPayments)

-

- {spenderAddress || "Not deployed on this network"} -

-
+
- {/* Token Address Input */} -
- - setTokenAddress(e.target.value)} - placeholder="0x..." - className="w-full px-4 py-3 border border-slate-200 rounded-lg text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition font-mono text-sm" - /> - {symbol && decimals !== undefined && ( -

- Token: {symbol} ({decimals} decimals) -

- )} -
- - {/* Amount Input */} -
- - setAmount(e.target.value)} - placeholder="100.0" - disabled={approveUnlimited} - className="w-full px-4 py-3 border border-slate-200 rounded-lg text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition disabled:bg-slate-100 disabled:cursor-not-allowed" - /> -
- - {/* Unlimited Checkbox */} -
- -
- - {/* Error Message */} - {error && ( -
- - {error} -
- )} +
+
+ +
+
+ +

+ {isPending ? "Confirm in Wallet" : "Confirming..."} +

+

+ {isPending ? "Check your wallet to approve" : "Waiting for confirmation"} +

+
+ ) : ( + /* Main Approval UI */ +
+ {/* Info Pills */} +
+ {/* Token Pill */} +
+ + {symbol || "Token"} + +
- {/* Submit Button */} - + {/* Spender Pill */} +
+ + + {spenderAddress ? `${spenderAddress.slice(0, 6)}...${spenderAddress.slice(-4)}` : "Not deployed"} + +
+
+ + {/* Amount Display */} +
+

{DEFAULT_AMOUNT}

+

{symbol || "tokens"} to approve

+
+ + {/* Error Message */} + {error && ( +
+ + {error}
)} -
-
- {/* Help Text */} - {isConnected && !isSuccess && ( -
-

This allows SplitHubPayments to transfer tokens on your behalf

-

Required before making gasless payments

+ {/* Approve Button - kept similar to original design */} + + + {/* Info text */} +

Required before making payments

)}
diff --git a/packages/nextjs/app/defi/page.tsx b/packages/nextjs/app/defi/page.tsx deleted file mode 100644 index 50cdbc5..0000000 --- a/packages/nextjs/app/defi/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { NextPage } from "next"; - -const DeFi: NextPage = () => { - return ( -
-

DeFi

-
- ); -}; - -export default DeFi; diff --git a/packages/nextjs/app/multi-sig/page.tsx b/packages/nextjs/app/multi-sig/page.tsx deleted file mode 100644 index 061764a..0000000 --- a/packages/nextjs/app/multi-sig/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { NextPage } from "next"; - -const MultiSig: NextPage = () => { - return ( -
-

Multi-Sig

-
- ); -}; - -export default MultiSig; diff --git a/packages/nextjs/app/page.tsx b/packages/nextjs/app/page.tsx index 6b152f1..ac8f74f 100644 --- a/packages/nextjs/app/page.tsx +++ b/packages/nextjs/app/page.tsx @@ -1,5 +1,57 @@ -import { redirect } from "next/navigation"; +"use client"; + +import { Cpu } from "lucide-react"; +import { useAccount } from "wagmi"; +import { useScaffoldReadContract } from "~~/hooks/scaffold-eth"; export default function Home() { - redirect("/pay"); + const { address, isConnected } = useAccount(); + + const { data: chipAddress, isLoading } = useScaffoldReadContract({ + contractName: "SplitHubRegistry", + functionName: "signerOf", + args: [address], + query: { + enabled: !!address, + }, + }); + + // Check if chip is registered (not zero address) + const hasChip = chipAddress && chipAddress !== "0x0000000000000000000000000000000000000000"; + + return ( +
+ {/* Chip Status Pill */} + {isConnected && ( +
+ {isLoading ? ( +
+
+ Loading chip... +
+ ) : hasChip ? ( +
+ + + {chipAddress?.slice(0, 6)}...{chipAddress?.slice(-4)} + + +
+ ) : ( +
+ + No chip registered +
+ )} +
+ )} + + {/* Welcome Message for disconnected users */} + {!isConnected && ( +
+

Connect your wallet to get started

+
+ )} +
+ ); } diff --git a/packages/nextjs/app/pay/page.tsx b/packages/nextjs/app/pay/page.tsx deleted file mode 100644 index 1d4bff3..0000000 --- a/packages/nextjs/app/pay/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { NextPage } from "next"; - -const Pay: NextPage = () => { - return ( -
-

Pay

-
- ); -}; - -export default Pay; diff --git a/packages/nextjs/app/re-register/page.tsx b/packages/nextjs/app/re-register/page.tsx index 4e9d594..6074097 100644 --- a/packages/nextjs/app/re-register/page.tsx +++ b/packages/nextjs/app/re-register/page.tsx @@ -1,17 +1,25 @@ "use client"; import { useState } from "react"; -import { AlertCircle, CheckCircle2, Loader2, Nfc, Wallet } from "lucide-react"; +import { AlertCircle, Check, Cpu, Loader2, Nfc, Wallet, Wrench } from "lucide-react"; import { useAccount } from "wagmi"; import { useHaloChip } from "~~/hooks/halochip-arx/useHaloChip"; -import { useDeployedContractInfo } from "~~/hooks/scaffold-eth"; +import { useDeployedContractInfo, useTargetNetwork } from "~~/hooks/scaffold-eth"; -type FlowState = "idle" | "tapping" | "registering" | "success" | "error"; +type FlowState = "idle" | "reading" | "signing" | "registering" | "success" | "error"; + +// Progress steps +const FLOW_STEPS = [ + { key: "reading", label: "Read" }, + { key: "signing", label: "Sign" }, + { key: "registering", label: "Register" }, +] as const; export default function ReRegisterPage() { const { address, isConnected } = useAccount(); const { signMessage, signTypedData } = useHaloChip(); const { data: registryContract } = useDeployedContractInfo("SplitHubRegistry"); + const { targetNetwork } = useTargetNetwork(); const [flowState, setFlowState] = useState("idle"); const [error, setError] = useState(""); @@ -32,13 +40,11 @@ export default function ReRegisterPage() { setError(""); setTxHash(null); - setFlowState("tapping"); - setStatusMessage("Hold your device near the NFC chip for 2-3 seconds..."); + setFlowState("reading"); + setStatusMessage("Tap chip"); try { // Step 1: Tap chip to detect its address - setStatusMessage("Reading chip..."); - const chipData = await signMessage({ message: "init", format: "text", @@ -46,11 +52,11 @@ export default function ReRegisterPage() { const detectedChipAddress = chipData.address; setChipAddress(detectedChipAddress); - setStatusMessage(`Chip detected: ${detectedChipAddress.slice(0, 10)}...`); // Step 2: Sign registration with EIP-712 - await new Promise(resolve => setTimeout(resolve, 500)); - setStatusMessage("Tap your chip again to authorize registration..."); + setFlowState("signing"); + setStatusMessage("Signing..."); + await new Promise(resolve => setTimeout(resolve, 300)); const registrationSig = await signTypedData({ domain: { @@ -74,7 +80,7 @@ export default function ReRegisterPage() { // Step 3: Register chip on-chain via relayer (gasless) setFlowState("registering"); - setStatusMessage("Registering chip on blockchain..."); + setStatusMessage("Registering..."); const relayResponse = await fetch("/api/relay/register", { method: "POST", @@ -92,16 +98,13 @@ export default function ReRegisterPage() { throw new Error(relayData.error || "Registration failed"); } - console.log("✅ Re-registration transaction:", relayData.txHash); setTxHash(relayData.txHash); - - // Success! setFlowState("success"); - setStatusMessage("Chip re-registered on-chain successfully!"); + setStatusMessage("Complete!"); } catch (err: any) { console.error("Re-registration error:", err); setFlowState("error"); - setError(err.message || "Re-registration failed. Please try again."); + setError(err.message || "Registration failed. Try again."); setStatusMessage(""); } }; @@ -114,143 +117,186 @@ export default function ReRegisterPage() { setTxHash(null); }; - return ( -
-
- {/* Header */} -
-

Re-register Chip

-
- - {/* Info Banner */} -
-

- Development Mode: Used for registering the chip with the newly deployed contract -

-
- - {/* Main Card */} -
-
- {!isConnected ? ( - /* Not Connected State */ -
-
- -
-

Connect Your Wallet

-

Please connect your wallet using the button in the header to continue

-
- ) : flowState === "success" ? ( - /* Success State */ -
-
- -
-

Re-registration Successful!

-

{statusMessage}

- - {chipAddress && ( -
-

Chip Address

-

{chipAddress}

-
- )} + // Helper to get current step index + const getCurrentStepIndex = () => { + const stepMap: Record = { + reading: 0, + signing: 1, + registering: 2, + }; + return stepMap[flowState] ?? -1; + }; - {txHash && ( -
-

Transaction Hash

-

{txHash}

-
- )} + const isProcessing = ["reading", "signing", "registering"].includes(flowState); - {registryContract && ( -
-

Registry Contract

-

{registryContract.address}

-
- )} - - + return ( +
+
+ {!isConnected ? ( + /* Not Connected State */ +
+
+ +
+

Connect your wallet to register

+
+ ) : flowState === "success" ? ( + /* Success State */ +
+
+ +
+

Registered!

+ + {/* Chip Address */} + {chipAddress && ( +
+ + + {chipAddress.slice(0, 6)}...{chipAddress.slice(-4)} +
- ) : ( - /* Main Flow */ -
- {/* Wallet Info */} -
-

Connected Wallet

-

{address}

-
+ )} - {/* Current Contract */} - {registryContract && ( -
-

Registry Contract

-

{registryContract.address}

-
- )} - - {/* Status Icon */} -
-
- {flowState === "idle" || flowState === "error" ? ( - - ) : ( - - )} -
- {statusMessage &&

{statusMessage}

} + {/* Transaction hash */} + {txHash && ( + + View transaction → + + )} - {chipAddress && ( -
-

Chip Address

-

{chipAddress}

+ +
+ ) : isProcessing ? ( + /* Processing States */ +
+ {/* Progress Steps */} +
+ {FLOW_STEPS.map((step, idx) => { + const currentIdx = getCurrentStepIndex(); + const isComplete = idx < currentIdx; + const isCurrent = idx === currentIdx; + return ( +
+
+ {isComplete ? : idx + 1}
- )} -
- - {/* Error Message */} - {error && ( -
- - {error} + {idx < FLOW_STEPS.length - 1 && ( +
+ )}
- )} - - {/* Action Button */} - + ); + })} +
+ + {/* Animated Processing Indicator */} +
+
+ +
+ {flowState === "reading" && ( + <> +
+
+ + )} +
+ +

{statusMessage}

+

+ {flowState === "reading" && "Hold device near chip"} + {flowState === "signing" && "Authorizing registration"} + {flowState === "registering" && "Writing to blockchain"} +

+ + {/* Show detected chip */} + {chipAddress && ( +
+ + + {chipAddress.slice(0, 6)}...{chipAddress.slice(-4)} +
)}
-
+ ) : ( + /* Main UI - Idle State */ +
+ {/* Dev Mode Badge */} +
+ + Dev Mode +
+ + {/* Info Pills */} +
+ {/* Wallet Pill */} +
+ + + {address?.slice(0, 6)}...{address?.slice(-4)} + +
+ + {/* Registry Pill */} + {registryContract && ( +
+ + + {registryContract.address.slice(0, 6)}...{registryContract.address.slice(-4)} + +
+ )} +
+ + {/* Title */} +
+

Register Chip

+

Link your NFC chip to your wallet

+
+ + {/* Error Message */} + {error && ( +
+ + {error} +
+ )} - {/* Help Text */} - {isConnected && flowState === "idle" && ( -
-

Make sure NFC is enabled on your device

-

Hold your device close to the chip for 2-3 seconds

+ {/* 3D NFC Chip Button */} +
+ {/* Pulse rings */} +
+
+
+ + +
)}
diff --git a/packages/nextjs/app/settle/page.tsx b/packages/nextjs/app/settle/page.tsx index fc75a57..64d6398 100644 --- a/packages/nextjs/app/settle/page.tsx +++ b/packages/nextjs/app/settle/page.tsx @@ -1,15 +1,17 @@ "use client"; import { useState } from "react"; -import { AlertCircle, CheckCircle2, Coins, Loader2, Nfc, Send, Wallet } from "lucide-react"; -import { isAddress, parseUnits } from "viem"; +import { AlertCircle, Check, Coins, Fuel, Loader2, Nfc, User, Wallet } from "lucide-react"; +import { parseUnits } from "viem"; import { useAccount, useReadContract } from "wagmi"; import deployedContracts from "~~/contracts/deployedContracts"; import { useHaloChip } from "~~/hooks/halochip-arx/useHaloChip"; import { useTargetNetwork } from "~~/hooks/scaffold-eth"; -// Hardcoded recipient address +// Hardcoded values const RECIPIENT_ADDRESS = "0x09a6f8C0194246c365bB42122E872626460F8a71" as const; +const DEFAULT_TOKEN_ADDRESS = "0x0a215D8ba66387DCA84B284D18c3B4ec3de6E54a" as const; +const DEFAULT_AMOUNT = "1"; const ERC20_ABI = [ { @@ -38,15 +40,21 @@ const SPLIT_HUB_PAYMENTS_ABI = [ }, ] as const; -type FlowState = "idle" | "tapping" | "submitting" | "success" | "error"; +type FlowState = "idle" | "tapping" | "signing" | "submitting" | "confirming" | "success" | "error"; + +// Progress steps for visual indicator +const FLOW_STEPS = [ + { key: "tapping", label: "Tap" }, + { key: "signing", label: "Sign" }, + { key: "submitting", label: "Send" }, + { key: "confirming", label: "Confirm" }, +] as const; export default function SettlePage() { const { address, isConnected } = useAccount(); const { targetNetwork } = useTargetNetwork(); const { signTypedData } = useHaloChip(); - const [tokenAddress, setTokenAddress] = useState(""); - const [amount, setAmount] = useState(""); const [flowState, setFlowState] = useState("idle"); const [statusMessage, setStatusMessage] = useState(""); const [error, setError] = useState(""); @@ -60,26 +68,20 @@ export default function SettlePage() { // Read token decimals const { data: decimals } = useReadContract({ - address: isAddress(tokenAddress) ? (tokenAddress as `0x${string}`) : undefined, + address: DEFAULT_TOKEN_ADDRESS, abi: ERC20_ABI, functionName: "decimals", - query: { - enabled: isAddress(tokenAddress), - }, }); // Read token symbol const { data: symbol } = useReadContract({ - address: isAddress(tokenAddress) ? (tokenAddress as `0x${string}`) : undefined, + address: DEFAULT_TOKEN_ADDRESS, abi: ERC20_ABI, functionName: "symbol", - query: { - enabled: isAddress(tokenAddress), - }, }); // Read current nonce for payer - const { data: currentNonce } = useReadContract({ + const { data: currentNonce, refetch: refetchNonce } = useReadContract({ address: paymentsAddress, abi: SPLIT_HUB_PAYMENTS_ABI, functionName: "nonces", @@ -98,16 +100,6 @@ export default function SettlePage() { return; } - if (!isAddress(tokenAddress)) { - setError("Please enter a valid token address"); - return; - } - - if (!amount || parseFloat(amount) <= 0) { - setError("Please enter a valid amount"); - return; - } - if (!paymentsAddress) { setError("SplitHubPayments contract not deployed on this network"); return; @@ -125,16 +117,16 @@ export default function SettlePage() { try { setFlowState("tapping"); - setStatusMessage("Hold your device near the NFC chip for 2-3 seconds..."); + setStatusMessage("Tap your chip"); // Build PaymentAuth struct - const amountInWei = parseUnits(amount, decimals); + const amountInWei = parseUnits(DEFAULT_AMOUNT, decimals); const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); // 1 hour from now const paymentAuth = { payer: address, recipient: RECIPIENT_ADDRESS, - token: tokenAddress as `0x${string}`, + token: DEFAULT_TOKEN_ADDRESS, amount: amountInWei, nonce: currentNonce, deadline: deadline, @@ -159,7 +151,9 @@ export default function SettlePage() { ], }; - setStatusMessage("Tap NFC chip to sign payment..."); + // Signing state + setFlowState("signing"); + setStatusMessage("Signing..."); // Sign with NFC chip const chipResult = await signTypedData({ @@ -169,8 +163,9 @@ export default function SettlePage() { message: paymentAuth, }); - setStatusMessage("Submitting payment to relay..."); + // Submitting state setFlowState("submitting"); + setStatusMessage("Sending..."); // Submit to relay API const response = await fetch("/api/relay/payment", { @@ -198,9 +193,19 @@ export default function SettlePage() { throw new Error(result.error || "Relay request failed"); } + // Confirming state + setFlowState("confirming"); + setStatusMessage("Confirming..."); setTxHash(result.txHash); + + // Brief delay to show confirming state + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Refetch nonce for next payment + await refetchNonce(); + setFlowState("success"); - setStatusMessage("Payment successful!"); + setStatusMessage("Complete!"); } catch (err: any) { console.error("Settlement error:", err); setFlowState("error"); @@ -211,147 +216,178 @@ export default function SettlePage() { const handleReset = () => { setFlowState("idle"); - setTokenAddress(""); - setAmount(""); setError(""); setStatusMessage(""); setTxHash(null); }; + // Helper to get current step index + const getCurrentStepIndex = () => { + const stepMap: Record = { + tapping: 0, + signing: 1, + submitting: 2, + confirming: 3, + }; + return stepMap[flowState] ?? -1; + }; + + const isProcessing = ["tapping", "signing", "submitting", "confirming"].includes(flowState); + return ( -
-
- {/* Header */} -
-
- +
+
+ {!isConnected ? ( + /* Not Connected State */ +
+
+ +
+

Connect your wallet to settle

+
+ ) : flowState === "success" ? ( + /* Success State */ +
+
+ +
+

Payment Complete

+ + {/* Amount sent */} +
+ + + {DEFAULT_AMOUNT} {symbol || "tokens"} sent + +
+ + {/* Transaction hash */} + {txHash && ( + + View transaction → + + )} + +
-

Settlement

-

Send tokens via NFC chip (gasless)

-
- - {/* Main Card */} -
-
- {!isConnected ? ( - /* Not Connected State */ -
-
- -
-

Connect Your Wallet

-

Please connect your wallet using the button in the header to continue

+ ) : isProcessing ? ( + /* Processing States */ +
+ {/* Progress Steps */} +
+ {FLOW_STEPS.map((step, idx) => { + const currentIdx = getCurrentStepIndex(); + const isComplete = idx < currentIdx; + const isCurrent = idx === currentIdx; + return ( +
+
+ {isComplete ? : idx + 1} +
+ {idx < FLOW_STEPS.length - 1 && ( +
+ )} +
+ ); + })} +
+ + {/* Animated Processing Indicator */} +
+
+
- ) : flowState === "success" ? ( - /* Success State */ -
-
- -
-

Payment Successful!

-

- {amount} {symbol || "tokens"} sent to recipient -

- {txHash && ( -

- Tx: {txHash.slice(0, 10)}...{txHash.slice(-8)} -

- )} - + {flowState === "tapping" && ( + <> +
+
+ + )} +
+ +

{statusMessage}

+

+ {flowState === "tapping" && "Hold device near chip"} + {flowState === "signing" && "Authorizing payment"} + {flowState === "submitting" && "Broadcasting to network"} + {flowState === "confirming" && "Waiting for confirmation"} +

+
+ ) : ( + /* Main Payment UI - Idle State */ +
+ {/* Info Pills */} +
+ {/* Recipient Pill */} +
+ + + {RECIPIENT_ADDRESS.slice(0, 6)}...{RECIPIENT_ADDRESS.slice(-4)} +
- ) : flowState === "tapping" || flowState === "submitting" ? ( - /* Processing State */ -
-
- -
-

- {flowState === "tapping" ? "Tap Your NFC Chip" : "Processing Payment"} -

-

{statusMessage}

+ + {/* Token Pill */} +
+ + {symbol || "Token"} +
- ) : ( - /* Form */ -
- {/* Recipient Info */} -
-

Recipient

-

{RECIPIENT_ADDRESS}

-
- - {/* Token Address Input */} -
- - setTokenAddress(e.target.value)} - placeholder="0x..." - className="w-full px-4 py-3 border border-slate-200 rounded-lg text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition font-mono text-sm" - /> - {symbol && decimals !== undefined && ( -

- Token: {symbol} ({decimals} decimals) -

- )} -
- - {/* Amount Input */} -
- - setAmount(e.target.value)} - placeholder="100.0" - className="w-full px-4 py-3 border border-slate-200 rounded-lg text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition" - /> -
- - {/* Current Nonce Info */} - {currentNonce !== undefined && ( -
Current nonce: {currentNonce.toString()}
- )} - - {/* Error Message */} - {error && ( -
- - {error} -
- )} - - {/* Submit Button */} - + + {/* Gasless Pill */} +
+ + Gasless +
+
+ + {/* Amount Display */} +
+

{DEFAULT_AMOUNT}

+

{symbol || "tokens"}

+
+ + {/* Error Message */} + {error && ( +
+ + {error}
)} -
-
- {/* Help Text */} - {isConnected && flowState === "idle" && ( -
-

Make sure you have approved the token first

-

Payment is gasless - relayer pays the gas

+ {/* 3D NFC Chip Button */} +
+ {/* Pulse rings */} +
+
+
+ + +
)}
diff --git a/packages/nextjs/components/BottomNav.tsx b/packages/nextjs/components/BottomNav.tsx new file mode 100644 index 0000000..37d75ca --- /dev/null +++ b/packages/nextjs/components/BottomNav.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Home, RefreshCw, Send, ShieldCheck } from "lucide-react"; + +const navItems = [ + { label: "Home", href: "/", icon: Home }, + { label: "Settle", href: "/settle", icon: Send }, + { label: "Approve", href: "/approve", icon: ShieldCheck }, + { label: "Re-register", href: "/re-register", icon: RefreshCw }, +]; + +export const BottomNav = () => { + const pathname = usePathname(); + + return ( + + ); +}; diff --git a/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx b/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx index a56b31c..42d0553 100644 --- a/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx +++ b/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx @@ -7,17 +7,17 @@ import { AppProgressBar as ProgressBar } from "next-nprogress-bar"; import { useTheme } from "next-themes"; import { Toaster } from "react-hot-toast"; import { WagmiProvider } from "wagmi"; -import { Header } from "~~/components/Header"; +import { BottomNav } from "~~/components/BottomNav"; +import { TopNav } from "~~/components/TopNav"; import { BlockieAvatar } from "~~/components/scaffold-eth"; import { wagmiConfig } from "~~/services/web3/wagmiConfig"; const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => { return ( <> -
-
-
{children}
-
+ +
{children}
+ ); diff --git a/packages/nextjs/components/TopNav.tsx b/packages/nextjs/components/TopNav.tsx new file mode 100644 index 0000000..6c082d3 --- /dev/null +++ b/packages/nextjs/components/TopNav.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useRef, useState } from "react"; +import Link from "next/link"; +import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { getAddress } from "viem"; +import { useAccount, useDisconnect } from "wagmi"; +import { + ArrowLeftOnRectangleIcon, + ArrowTopRightOnSquareIcon, + ArrowsRightLeftIcon, + CheckCircleIcon, + ChevronDownIcon, + DocumentDuplicateIcon, + EyeIcon, + QrCodeIcon, +} from "@heroicons/react/24/outline"; +import { BlockieAvatar } from "~~/components/scaffold-eth"; +import { AddressQRCodeModal } from "~~/components/scaffold-eth/RainbowKitCustomConnectButton/AddressQRCodeModal"; +import { NetworkOptions } from "~~/components/scaffold-eth/RainbowKitCustomConnectButton/NetworkOptions"; +import { RevealBurnerPKModal } from "~~/components/scaffold-eth/RainbowKitCustomConnectButton/RevealBurnerPKModal"; +import { useCopyToClipboard, useOutsideClick } from "~~/hooks/scaffold-eth"; +import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork"; +import { getBlockExplorerAddressLink, getTargetNetworks } from "~~/utils/scaffold-eth"; + +const BURNER_WALLET_ID = "burnerWallet"; +const allowedNetworks = getTargetNetworks(); + +export const TopNav = () => { + const { targetNetwork } = useTargetNetwork(); + const { disconnect } = useDisconnect(); + const { connector } = useAccount(); + const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } = + useCopyToClipboard(); + const [selectingNetwork, setSelectingNetwork] = useState(false); + const dropdownRef = useRef(null); + + const closeDropdown = () => { + setSelectingNetwork(false); + dropdownRef.current?.removeAttribute("open"); + }; + + useOutsideClick(dropdownRef, closeDropdown); + + return ( + + {({ account, chain, openConnectModal, mounted }) => { + const connected = mounted && account && chain; + const blockExplorerAddressLink = account + ? getBlockExplorerAddressLink(targetNetwork, account.address) + : undefined; + + const checkSumAddress = account ? getAddress(account.address) : undefined; + + return ( + + ); + }} + + ); +}; diff --git a/packages/nextjs/styles/globals.css b/packages/nextjs/styles/globals.css index a070915..bd76004 100644 --- a/packages/nextjs/styles/globals.css +++ b/packages/nextjs/styles/globals.css @@ -18,22 +18,22 @@ @plugin "daisyui/theme" { name: "light"; - --color-primary: #93bbfb; - --color-primary-content: #212638; - --color-secondary: #dae8ff; - --color-secondary-content: #212638; - --color-accent: #93bbfb; - --color-accent-content: #212638; - --color-neutral: #212638; + --color-primary: #ffa31a; + --color-primary-content: #1b1b1b; + --color-secondary: #292929; + --color-secondary-content: #ffffff; + --color-accent: #ffa31a; + --color-accent-content: #1b1b1b; + --color-neutral: #292929; --color-neutral-content: #ffffff; - --color-base-100: #ffffff; - --color-base-200: #f4f8ff; - --color-base-300: #dae8ff; - --color-base-content: #212638; - --color-info: #93bbfb; - --color-success: #34eeb6; - --color-warning: #ffcf72; - --color-error: #ff8863; + --color-base-100: #1b1b1b; + --color-base-200: #292929; + --color-base-300: #3a3a3a; + --color-base-content: #ffffff; + --color-info: #ffa31a; + --color-success: #22c55e; + --color-warning: #ffa31a; + --color-error: #ef4444; --radius-field: 9999rem; --radius-box: 1rem; @@ -43,22 +43,22 @@ @plugin "daisyui/theme" { name: "dark"; - --color-primary: #212638; - --color-primary-content: #f9fbff; - --color-secondary: #323f61; - --color-secondary-content: #f9fbff; - --color-accent: #4969a6; - --color-accent-content: #f9fbff; - --color-neutral: #f9fbff; - --color-neutral-content: #385183; - --color-base-100: #385183; - --color-base-200: #2a3655; - --color-base-300: #212638; - --color-base-content: #f9fbff; - --color-info: #385183; - --color-success: #34eeb6; - --color-warning: #ffcf72; - --color-error: #ff8863; + --color-primary: #ffa31a; + --color-primary-content: #1b1b1b; + --color-secondary: #292929; + --color-secondary-content: #ffffff; + --color-accent: #ffa31a; + --color-accent-content: #1b1b1b; + --color-neutral: #808080; + --color-neutral-content: #ffffff; + --color-base-100: #1b1b1b; + --color-base-200: #292929; + --color-base-300: #3a3a3a; + --color-base-content: #ffffff; + --color-info: #ffa31a; + --color-success: #22c55e; + --color-warning: #ffa31a; + --color-error: #ef4444; --radius-field: 9999rem; --radius-box: 1rem; @@ -121,3 +121,171 @@ .link:hover { opacity: 80%; } + +/* 3D NFC Chip Button */ +.nfc-chip-btn { + position: relative; + width: 160px; + height: 160px; + border-radius: 50%; + background: linear-gradient(145deg, #ffa31a, #e6921a); + box-shadow: + 0 8px 32px rgba(255, 163, 26, 0.4), + 0 4px 16px rgba(0, 0, 0, 0.3), + inset 0 2px 4px rgba(255, 255, 255, 0.2), + inset 0 -2px 4px rgba(0, 0, 0, 0.2); + border: 3px solid rgba(255, 255, 255, 0.1); + transition: all 0.15s ease-out; + cursor: pointer; +} + +.nfc-chip-btn::before { + content: ""; + position: absolute; + inset: 8px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.15); + pointer-events: none; +} + +.nfc-chip-btn::after { + content: ""; + position: absolute; + inset: 16px; + border-radius: 50%; + background: linear-gradient(145deg, rgba(255, 255, 255, 0.1), transparent); + pointer-events: none; +} + +.nfc-chip-btn:hover:not(:disabled) { + box-shadow: + 0 12px 40px rgba(255, 163, 26, 0.5), + 0 6px 20px rgba(0, 0, 0, 0.35), + inset 0 2px 4px rgba(255, 255, 255, 0.2), + inset 0 -2px 4px rgba(0, 0, 0, 0.2); + transform: translateY(-2px); +} + +.nfc-chip-btn:active:not(:disabled) { + box-shadow: + 0 4px 16px rgba(255, 163, 26, 0.3), + 0 2px 8px rgba(0, 0, 0, 0.25), + inset 0 2px 8px rgba(0, 0, 0, 0.2); + transform: translateY(1px) scale(0.98); +} + +.nfc-chip-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* NFC Pulse Ring Animation */ +.nfc-pulse-ring { + position: absolute; + inset: -12px; + border-radius: 50%; + border: 2px solid rgba(255, 163, 26, 0.6); + animation: nfc-pulse 2s ease-out infinite; +} + +.nfc-pulse-ring:nth-child(2) { + animation-delay: 0.5s; +} + +.nfc-pulse-ring:nth-child(3) { + animation-delay: 1s; +} + +@keyframes nfc-pulse { + 0% { + transform: scale(1); + opacity: 0.8; + } + 100% { + transform: scale(1.4); + opacity: 0; + } +} + +/* NFC Wave Animation */ +@keyframes nfc-wave { + 0%, + 100% { + transform: scale(0.8); + opacity: 0.3; + } + 50% { + transform: scale(1); + opacity: 1; + } +} + +.nfc-wave { + animation: nfc-wave 1.5s ease-in-out infinite; +} + +/* Scanning Animation */ +@keyframes scan-wave { + 0% { + transform: translateY(-100%); + opacity: 0; + } + 50% { + opacity: 1; + } + 100% { + transform: translateY(100%); + opacity: 0; + } +} + +.scan-wave { + animation: scan-wave 1.5s ease-in-out infinite; +} + +/* Draw Check Animation */ +@keyframes draw-check { + 0% { + stroke-dashoffset: 100; + } + 100% { + stroke-dashoffset: 0; + } +} + +.draw-check { + stroke-dasharray: 100; + stroke-dashoffset: 100; + animation: draw-check 0.6s ease-out forwards; +} + +/* Fade In Up Animation */ +@keyframes fade-in-up { + 0% { + opacity: 0; + transform: translateY(10px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.fade-in-up { + animation: fade-in-up 0.4s ease-out forwards; +} + +/* Success Glow */ +@keyframes success-glow { + 0%, + 100% { + box-shadow: 0 0 20px rgba(34, 197, 94, 0.4); + } + 50% { + box-shadow: 0 0 40px rgba(34, 197, 94, 0.6); + } +} + +.success-glow { + animation: success-glow 2s ease-in-out infinite; +} From 5b492e10d4a50672a5d06d68f9a1ce6ca268c998 Mon Sep 17 00:00:00 2001 From: George Mathew <97821867+0xgeorgemathew@users.noreply.github.com> Date: Sun, 30 Nov 2025 09:08:37 +0530 Subject: [PATCH 2/7] railway config update --- railpack.json | 37 +++++++++++++++++++++++++++++++++++++ railway.json | 16 ++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 railpack.json create mode 100644 railway.json diff --git a/railpack.json b/railpack.json new file mode 100644 index 0000000..e6bb6f3 --- /dev/null +++ b/railpack.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://schema.railpack.com", + "steps": { + "install": { + "cache": [ + "node_modules", + ".yarn/cache" + ], + "commands": [ + "corepack enable", + "corepack prepare yarn@3.2.3 --activate", + "yarn install --immutable" + ] + }, + "build": { + "inputs": [ + { + "step": "install" + } + ], + "cache": [ + "packages/nextjs/.next/cache" + ], + "env": { + "NODE_ENV": "production", + "NEXT_TELEMETRY_DISABLED": "1", + "SKIP_ENV_VALIDATION": "true" + }, + "commands": [ + "yarn workspace @se-2/nextjs build" + ] + } + }, + "deploy": { + "startCommand": "yarn workspace @se-2/nextjs serve" + } +} diff --git a/railway.json b/railway.json new file mode 100644 index 0000000..1a2d6be --- /dev/null +++ b/railway.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "RAILPACK", + "watchPatterns": [ + "packages/nextjs/**" + ] + }, + "deploy": { + "runtime": "V2", + "numReplicas": 1, + "sleepApplication": false, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 + } +} From f990d2d1464fc9d98bce92e4f7df72038cd1c3a2 Mon Sep 17 00:00:00 2001 From: George Mathew <97821867+0xgeorgemathew@users.noreply.github.com> Date: Sun, 30 Nov 2025 09:09:08 +0530 Subject: [PATCH 3/7] Updated Routes --- .../app/api/payment-requests/[id]/route.ts | 100 ++++++++++ .../nextjs/app/api/payment-requests/route.ts | 66 +++++++ .../app/api/relay/batch-payment/route.ts | 177 ++++++++++++++++++ 3 files changed, 343 insertions(+) create mode 100644 packages/nextjs/app/api/payment-requests/[id]/route.ts create mode 100644 packages/nextjs/app/api/payment-requests/route.ts create mode 100644 packages/nextjs/app/api/relay/batch-payment/route.ts diff --git a/packages/nextjs/app/api/payment-requests/[id]/route.ts b/packages/nextjs/app/api/payment-requests/[id]/route.ts new file mode 100644 index 0000000..602f0d6 --- /dev/null +++ b/packages/nextjs/app/api/payment-requests/[id]/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import { supabase } from "~~/lib/supabase"; + +interface RouteParams { + params: { + id: string; + }; +} + +// GET /api/payment-requests/[id] - Fetch a payment request by ID +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { id } = params; + + if (!id) { + return NextResponse.json({ error: "Missing request ID" }, { status: 400 }); + } + + const { data, error } = await supabase.from("payment_requests").select("*").eq("id", id).single(); + + if (error || !data) { + return NextResponse.json({ error: "Payment request not found" }, { status: 404 }); + } + + // Check if expired + if (new Date(data.expires_at) < new Date() && data.status === "pending") { + // Update status to expired + await supabase.from("payment_requests").update({ status: "expired" }).eq("id", id); + + return NextResponse.json({ error: "Payment request has expired" }, { status: 410 }); + } + + return NextResponse.json(data); + } catch (err) { + console.error("Payment request fetch error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// PATCH /api/payment-requests/[id] - Update a payment request (mark as completed) +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const { id } = params; + const body = await request.json(); + const { status, tx_hash } = body; + + if (!id) { + return NextResponse.json({ error: "Missing request ID" }, { status: 400 }); + } + + // Only allow updating to 'completed' status + if (status !== "completed") { + return NextResponse.json({ error: "Invalid status update" }, { status: 400 }); + } + + if (!tx_hash) { + return NextResponse.json({ error: "Transaction hash required for completion" }, { status: 400 }); + } + + // Verify the request exists and is pending + const { data: existingRequest, error: fetchError } = await supabase + .from("payment_requests") + .select("status") + .eq("id", id) + .single(); + + if (fetchError || !existingRequest) { + return NextResponse.json({ error: "Payment request not found" }, { status: 404 }); + } + + if (existingRequest.status !== "pending") { + return NextResponse.json( + { error: `Cannot update request with status: ${existingRequest.status}` }, + { status: 400 }, + ); + } + + // Update the request + const { data, error } = await supabase + .from("payment_requests") + .update({ + status: "completed", + tx_hash, + completed_at: new Date().toISOString(), + }) + .eq("id", id) + .select() + .single(); + + if (error) { + console.error("Supabase update error:", error); + return NextResponse.json({ error: "Failed to update payment request" }, { status: 500 }); + } + + return NextResponse.json(data); + } catch (err) { + console.error("Payment request update error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/packages/nextjs/app/api/payment-requests/route.ts b/packages/nextjs/app/api/payment-requests/route.ts new file mode 100644 index 0000000..58b9f8a --- /dev/null +++ b/packages/nextjs/app/api/payment-requests/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isAddress } from "viem"; +import { supabase } from "~~/lib/supabase"; + +// POST /api/payment-requests - Create a new payment request +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { payer, recipient, token, amount, memo } = body; + + // Validate required fields + if (!payer || !recipient || !token || !amount) { + return NextResponse.json({ error: "Missing required fields: payer, recipient, token, amount" }, { status: 400 }); + } + + // Validate addresses + if (!isAddress(payer)) { + return NextResponse.json({ error: "Invalid payer address" }, { status: 400 }); + } + + if (!isAddress(recipient)) { + return NextResponse.json({ error: "Invalid recipient address" }, { status: 400 }); + } + + if (!isAddress(token)) { + return NextResponse.json({ error: "Invalid token address" }, { status: 400 }); + } + + // Validate amount + const parsedAmount = parseFloat(amount); + if (isNaN(parsedAmount) || parsedAmount <= 0) { + return NextResponse.json({ error: "Invalid amount" }, { status: 400 }); + } + + // Set expiration to 24 hours from now + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + + // Insert into database + const { data, error } = await supabase + .from("payment_requests") + .insert({ + payer: payer.toLowerCase(), + recipient: recipient.toLowerCase(), + token: token.toLowerCase(), + amount: amount.toString(), + memo: memo || null, + status: "pending", + expires_at: expiresAt, + }) + .select() + .single(); + + if (error) { + console.error("Supabase error:", error); + return NextResponse.json({ error: "Failed to create payment request" }, { status: 500 }); + } + + return NextResponse.json({ + requestId: data.id, + settleUrl: `/settle/${data.id}`, + }); + } catch (err) { + console.error("Payment request creation error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/packages/nextjs/app/api/relay/batch-payment/route.ts b/packages/nextjs/app/api/relay/batch-payment/route.ts new file mode 100644 index 0000000..5a088aa --- /dev/null +++ b/packages/nextjs/app/api/relay/batch-payment/route.ts @@ -0,0 +1,177 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Abi, encodeFunctionData } from "viem"; +import { createPublicClient, createWalletClient, http, isAddress } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; +import deployedContracts from "~~/contracts/deployedContracts"; + +const CHAIN_ID = 84532; // Base Sepolia + +// Multicall3 address (same on all chains) +const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11" as const; + +// PaymentAuth struct +interface PaymentAuth { + payer: `0x${string}`; + recipient: `0x${string}`; + token: `0x${string}`; + amount: string; + nonce: string; + deadline: string; + signature: string; +} + +// ABI for SplitHubPayments executePayment +const SPLIT_HUB_PAYMENTS_ABI: Abi = [ + { + type: "function", + name: "executePayment", + inputs: [ + { + name: "auth", + type: "tuple", + components: [ + { name: "payer", type: "address" }, + { name: "recipient", type: "address" }, + { name: "token", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }, + { name: "signature", type: "bytes" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, +]; + +// Multicall3 ABI for aggregate3 +const MULTICALL3_ABI: Abi = [ + { + type: "function", + name: "aggregate3", + inputs: [ + { + name: "calls", + type: "tuple[]", + components: [ + { name: "target", type: "address" }, + { name: "allowFailure", type: "bool" }, + { name: "callData", type: "bytes" }, + ], + }, + ], + outputs: [ + { + name: "returnData", + type: "tuple[]", + components: [ + { name: "success", type: "bool" }, + { name: "returnData", type: "bytes" }, + ], + }, + ], + stateMutability: "payable", + }, +]; + +export async function POST(request: NextRequest) { + try { + const { payments, contractAddress } = await request.json(); + + // Validate inputs + if (!payments || !Array.isArray(payments) || payments.length === 0) { + return NextResponse.json({ error: "Missing or empty payments array" }, { status: 400 }); + } + + // Validate each payment + for (const payment of payments as PaymentAuth[]) { + const { payer, recipient, token, amount, nonce, deadline, signature } = payment; + + if (!payer || !recipient || !token || !amount || !nonce || !deadline || !signature) { + return NextResponse.json({ error: "Invalid payment: missing required fields" }, { status: 400 }); + } + + if (!isAddress(payer) || !isAddress(recipient) || !isAddress(token)) { + return NextResponse.json({ error: "Invalid address format in payment" }, { status: 400 }); + } + } + + // Get relayer private key + const relayerKey = process.env.RELAYER_PRIVATE_KEY; + if (!relayerKey) { + return NextResponse.json({ error: "Relayer not configured" }, { status: 500 }); + } + + // Get contract address + const chainContracts = deployedContracts[CHAIN_ID] as Record | undefined; + const paymentsAddress = chainContracts?.SplitHubPayments?.address || contractAddress; + + if (!paymentsAddress || !isAddress(paymentsAddress)) { + return NextResponse.json( + { error: "SplitHubPayments not deployed. Provide contractAddress in request body." }, + { status: 500 }, + ); + } + + // Create wallet client + const account = privateKeyToAccount(relayerKey as `0x${string}`); + const walletClient = createWalletClient({ + account, + chain: baseSepolia, + transport: http(), + }); + + const publicClient = createPublicClient({ + chain: baseSepolia, + transport: http(), + }); + + // Build multicall calls array + const calls = (payments as PaymentAuth[]).map(payment => { + const authTuple = { + payer: payment.payer, + recipient: payment.recipient, + token: payment.token, + amount: BigInt(payment.amount), + nonce: BigInt(payment.nonce), + deadline: BigInt(payment.deadline), + }; + + const callData = encodeFunctionData({ + abi: SPLIT_HUB_PAYMENTS_ABI, + functionName: "executePayment", + args: [authTuple, payment.signature], + }); + + return { + target: paymentsAddress as `0x${string}`, + allowFailure: false, // Fail entire batch if any call fails + callData, + }; + }); + + // Execute batch via Multicall3 + const hash = await walletClient.writeContract({ + address: MULTICALL3_ADDRESS, + abi: MULTICALL3_ABI, + functionName: "aggregate3", + args: [calls], + }); + + // Wait for confirmation + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + + return NextResponse.json({ + success: true, + txHash: hash, + blockNumber: receipt.blockNumber.toString(), + paymentsCount: payments.length, + }); + } catch (error) { + console.error("Batch relay payment error:", error); + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} From a0655eccddf879a71f062b0b5401aa44ac0d6c17 Mon Sep 17 00:00:00 2001 From: George Mathew <97821867+0xgeorgemathew@users.noreply.github.com> Date: Sun, 30 Nov 2025 09:09:47 +0530 Subject: [PATCH 4/7] Multi Settle --- packages/nextjs/app/multi-settle/page.tsx | 209 ++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 packages/nextjs/app/multi-settle/page.tsx diff --git a/packages/nextjs/app/multi-settle/page.tsx b/packages/nextjs/app/multi-settle/page.tsx new file mode 100644 index 0000000..e4b03b6 --- /dev/null +++ b/packages/nextjs/app/multi-settle/page.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useState } from "react"; +import { Coins, Fuel, Plus, Trash2, User, Users } from "lucide-react"; +import { isAddress } from "viem"; +import { MultiSettleFlow } from "~~/components/settle"; + +// Demo/default values - in production these would come from a split creation flow +const DEFAULT_RECIPIENT = "0x09a6f8C0194246c365bB42122E872626460F8a71" as const; +const DEFAULT_TOKEN = "0x0a215D8ba66387DCA84B284D18c3B4ec3de6E54a" as const; + +interface AmountSlot { + id: string; + amount: string; +} + +export default function MultiSettlePage() { + const [isConfiguring, setIsConfiguring] = useState(true); + const [recipient, setRecipient] = useState(DEFAULT_RECIPIENT); + const [token, setToken] = useState(DEFAULT_TOKEN); + const [memo, setMemo] = useState(""); + const [slots, setSlots] = useState([{ id: "1", amount: "" }]); + + const addSlot = () => { + setSlots(prev => [...prev, { id: Date.now().toString(), amount: "" }]); + }; + + const removeSlot = (id: string) => { + if (slots.length > 1) { + setSlots(prev => prev.filter(s => s.id !== id)); + } + }; + + const updateSlot = (id: string, amount: string) => { + setSlots(prev => prev.map(s => (s.id === id ? { ...s, amount } : s))); + }; + + const isValidConfig = () => { + if (!isAddress(recipient) || !isAddress(token)) return false; + return slots.every(s => s.amount && parseFloat(s.amount) > 0); + }; + + const startMultiSettle = () => { + if (isValidConfig()) { + setIsConfiguring(false); + } + }; + + const handleSuccess = (txHash: string) => { + console.log("Batch payment complete:", txHash); + // Could redirect or show success state + }; + + // Calculate total amount for display + const totalAmount = slots.reduce((sum, s) => sum + (parseFloat(s.amount) || 0), 0); + + if (!isConfiguring) { + return ( +
+
+ {/* Header */} +
+ +
+ + Multi-Settle +
+
+ + s.amount)} + memo={memo || undefined} + onSuccess={handleSuccess} + /> +
+
+ ); + } + + // Configuration UI + return ( +
+
+ {/* Info Pills - matching settle page style */} +
+
+ + + {recipient ? `${recipient.slice(0, 6)}...${recipient.slice(-4)}` : "Recipient"} + +
+ +
+ + Token + +
+ +
+ + Gasless +
+
+ + {/* Total Amount Display - large like settle page */} +
+

{totalAmount || 0}

+

+ total from {slots.length} {slots.length === 1 ? "participant" : "participants"} +

+
+ + {/* Batch Icon */} +
+ +
+ + {/* Configuration Cards */} +
+ {/* Recipient */} +
+ + setRecipient(e.target.value)} + placeholder="0x..." + className="w-full bg-base-200 rounded-lg px-3 py-2 text-sm font-mono text-base-content focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ + {/* Token */} +
+ + setToken(e.target.value)} + placeholder="0x..." + className="w-full bg-base-200 rounded-lg px-3 py-2 text-sm font-mono text-base-content focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ + {/* Memo */} +
+ + setMemo(e.target.value)} + placeholder="Dinner split..." + className="w-full bg-base-200 rounded-lg px-3 py-2 text-sm text-base-content focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ + {/* Amount Slots */} +
+
+ + +
+ +

Payers are auto-detected when they tap their NFC chip.

+ +
+ {slots.map((slot, idx) => ( +
+
+ {idx + 1} +
+
+ updateSlot(slot.id, e.target.value)} + placeholder="Amount" + className="w-full bg-base-200 rounded-lg px-3 py-2 text-sm text-base-content focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ {slots.length > 1 && ( + + )} +
+ ))} +
+
+
+ + {/* Start Button - rounded-full like settle page */} + +
+
+ ); +} From 44b7d6b59d712ef7765f4b3449a25a7ebbdca41e Mon Sep 17 00:00:00 2001 From: George Mathew <97821867+0xgeorgemathew@users.noreply.github.com> Date: Sun, 30 Nov 2025 09:10:16 +0530 Subject: [PATCH 5/7] Multi Settle --- packages/nextjs/components/BottomNav.tsx | 3 +- .../components/settle/MultiSettleFlow.tsx | 262 ++++++++++++++++++ 2 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 packages/nextjs/components/settle/MultiSettleFlow.tsx diff --git a/packages/nextjs/components/BottomNav.tsx b/packages/nextjs/components/BottomNav.tsx index 37d75ca..55ad81d 100644 --- a/packages/nextjs/components/BottomNav.tsx +++ b/packages/nextjs/components/BottomNav.tsx @@ -2,11 +2,12 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Home, RefreshCw, Send, ShieldCheck } from "lucide-react"; +import { Home, RefreshCw, Send, ShieldCheck, Users } from "lucide-react"; const navItems = [ { label: "Home", href: "/", icon: Home }, { label: "Settle", href: "/settle", icon: Send }, + { label: "Multi", href: "/multi-settle", icon: Users }, { label: "Approve", href: "/approve", icon: ShieldCheck }, { label: "Re-register", href: "/re-register", icon: RefreshCw }, ]; diff --git a/packages/nextjs/components/settle/MultiSettleFlow.tsx b/packages/nextjs/components/settle/MultiSettleFlow.tsx new file mode 100644 index 0000000..3acd388 --- /dev/null +++ b/packages/nextjs/components/settle/MultiSettleFlow.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useMultiSettleFlow } from "./hooks/useMultiSettleFlow"; +import { MultiSettleFlowProps } from "./types"; +import { AlertCircle, Check, Coins, Fuel, Loader2, Nfc, User, Users, Wallet } from "lucide-react"; +import { useTargetNetwork } from "~~/hooks/scaffold-eth"; + +export function MultiSettleFlow({ recipient, token, amounts, memo, onSuccess, onError }: MultiSettleFlowProps) { + const { targetNetwork } = useTargetNetwork(); + const { + flowState, + participants, + currentSigningIndex, + allSigned, + signedCount, + totalCount, + error, + txHash, + symbol, + isConnected, + paymentsAddress, + totalAmount, + signSlot, + submitBatch, + reset, + } = useMultiSettleFlow({ + recipient, + token, + amounts, + onSuccess, + onError, + }); + + if (!isConnected) { + return ( +
+
+ +
+

Connect your wallet to continue

+
+ ); + } + + // Success state + if (flowState === "success") { + return ( +
+
+ +
+

All Payments Complete

+ +
+ + + {totalAmount} {symbol || "tokens"} from {totalCount} participants + +
+ + {txHash && ( + + View transaction → + + )} + + +
+ ); + } + + // Submitting/Confirming state + if (flowState === "submitting" || flowState === "confirming") { + return ( +
+
+
+ +
+
+ +

+ {flowState === "submitting" ? "Submitting Batch..." : "Confirming..."} +

+

+ {flowState === "submitting" + ? `Sending ${totalCount} payments atomically` + : "Waiting for blockchain confirmation"} +

+
+ ); + } + + // Error state at batch level + if (flowState === "error") { + return ( +
+
+ +
+

Batch Failed

+

{error}

+ +
+ ); + } + + // Collecting signatures state + return ( +
+ {/* Header Info */} +
+
+ + + To: {recipient.slice(0, 6)}...{recipient.slice(-4)} + +
+ +
+ + {symbol || "Token"} +
+ +
+ + Gasless +
+
+ + {memo &&

{memo}

} + + {/* Total Amount */} +
+

{totalAmount}

+

+ {symbol || "tokens"} total from {totalCount} participants +

+
+ + {/* Progress */} +
+ + + {signedCount} / {totalCount} signed + + {allSigned && } +
+ + {/* Participant Slots */} +
+ {participants.map((participant, idx) => ( +
+
+
+ {participant.status === "signed" ? ( + + ) : participant.status === "signing" ? ( + + ) : ( + idx + 1 + )} +
+
+ {participant.status === "signed" && participant.payer ? ( +

+ {participant.payer.slice(0, 6)}...{participant.payer.slice(-4)} +

+ ) : ( +

Slot {idx + 1}

+ )} +

+ {participant.expectedAmount} {symbol || "tokens"} +

+
+
+ + {participant.status === "waiting" && ( + + )} + + {participant.status === "signing" && Tapping...} + + {participant.status === "error" && ( + + )} +
+ ))} +
+ + {/* Error Message */} + {error && ( +
+ + {error} +
+ )} + + {/* Submit Button */} + +
+ ); +} From 37bb6c411f54c0d92b1281630b1b0dfbc0568fc5 Mon Sep 17 00:00:00 2001 From: George Mathew <97821867+0xgeorgemathew@users.noreply.github.com> Date: Sun, 30 Nov 2025 09:11:16 +0530 Subject: [PATCH 6/7] Payment Request Infra --- hype.md | 384 +++++++++++++++++ .../nextjs/app/settle/[requestId]/page.tsx | 162 +++++++ packages/nextjs/components/TopNav.tsx | 4 +- .../nextjs/components/settle/SettleFlow.tsx | 190 +++++++++ .../nextjs/components/settle/SettleModal.tsx | 76 ++++ .../settle/hooks/useMultiSettleFlow.ts | 400 ++++++++++++++++++ .../settle/hooks/usePaymentRequest.ts | 123 ++++++ .../components/settle/hooks/useSettleFlow.ts | 245 +++++++++++ packages/nextjs/components/settle/index.ts | 22 + packages/nextjs/components/settle/types.ts | 127 ++++++ packages/nextjs/lib/supabase.ts | 15 + 11 files changed, 1746 insertions(+), 2 deletions(-) create mode 100644 hype.md create mode 100644 packages/nextjs/app/settle/[requestId]/page.tsx create mode 100644 packages/nextjs/components/settle/SettleFlow.tsx create mode 100644 packages/nextjs/components/settle/SettleModal.tsx create mode 100644 packages/nextjs/components/settle/hooks/useMultiSettleFlow.ts create mode 100644 packages/nextjs/components/settle/hooks/usePaymentRequest.ts create mode 100644 packages/nextjs/components/settle/hooks/useSettleFlow.ts create mode 100644 packages/nextjs/components/settle/index.ts create mode 100644 packages/nextjs/components/settle/types.ts diff --git a/hype.md b/hype.md new file mode 100644 index 0000000..d17fd48 --- /dev/null +++ b/hype.md @@ -0,0 +1,384 @@ +# HyperEVM Testnet: Complete Developer Guide + +Hyperliquid's HyperEVM is not a separate blockchain—it's an Ethereum-compatible execution environment embedded directly into the Hyperliquid L1, secured by the same HyperBFT consensus. This architecture enables smart contracts to **read HyperCore state** (positions, balances, oracle prices) and **write trading actions** directly to the native order books, creating unprecedented composability between DeFi smart contracts and high-performance perpetual trading. + +## HyperEVM architecture and L1 integration + +HyperEVM operates as an extension of Hyperliquid's two-component blockchain architecture. **HyperCore** handles all trading activities—perpetual futures, spot markets, staking, and liquidations—while **HyperEVM** provides the smart contract layer. Both share identical consensus and finality, meaning EVM transactions receive the same **sub-second finality** (~0.2s median latency) as native trading operations. + +The technical foundation is the **Cancun EVM specification** (without blob transactions) running on **Chain ID 998** for testnet (999 for mainnet). Unlike traditional EVM chains, both base fees and priority fees are burned due to HyperBFT consensus. HYPE serves as the native gas token with **18 decimals**. + +### How HyperEVM connects to HyperCore + +The connection happens through two key mechanisms that enable bidirectional communication: + +**Read Precompiles** (addresses starting at `0x0000000000000000000000000000000000000800`) allow smart contracts to query HyperCore data directly: +- `0x...0800`: User perp positions (size, leverage, entry price) +- `0x...0801`: Spot balances +- `0x...0806`: Mark prices for perps +- `0x...0807`: Oracle prices for perps + +**CoreWriter System Contract** at `0x3333333333333333333333333333333333333333` enables smart contracts to submit orders, transfer between spot/perp accounts, and execute other HyperCore actions. Order actions are intentionally delayed **2-3 seconds** on-chain to prevent latency arbitrage. + +The relationship to perp trading is direct: lending protocols can read prices from HyperCore order books and execute liquidations via CoreWriter in just a few lines of Solidity. A smart contract can query a user's position, check collateral ratios against real-time prices, and submit liquidation orders—all without external oracles or bridges. + +## Testnet wallet configuration + +Add HyperEVM testnet to MetaMask with these exact parameters: + +| Parameter | Value | +|-----------|-------| +| **Network Name** | Hyperliquid EVM Testnet | +| **RPC URL** | `https://rpc.hyperliquid-testnet.xyz/evm` | +| **Chain ID** | 998 | +| **Currency Symbol** | HYPE | +| **Block Explorer** | `https://testnet.purrsec.com/` | + +Alternative RPC endpoints include `https://hyperliquid-testnet.drpc.org` and Alchemy's `https://hyperliquid-testnet.g.alchemy.com/`. + +### Asset transfers between HyperCore and HyperEVM + +HYPE moves between environments via the system address `0x2222222222222222222222222222222222222222`. Send HYPE to this address from either your HyperCore spot balance or HyperEVM wallet—tokens arrive at the same address on the opposite side. **Only HYPE works with this address**; sending other tokens results in permanent loss. + +For other tokens, each spot asset has a unique bridge address following the pattern `0x2000...` + token index in big-endian hex. + +## Testnet token acquisition + +Two tokens are required: **USDC** for perp margin and **HYPE** for gas. + +### Getting testnet USDC +The official faucet at `https://app.hyperliquid-testnet.xyz/drip` provides **1,000 mock USDC** every 4 hours. One prerequisite: your wallet must have deposited on mainnet first (even a tiny amount) to activate the address. + +### Getting testnet HYPE + +| Faucet | Amount | Rate Limit | Requirements | +|--------|--------|------------|--------------| +| Chainstack (`chainstack.com/faucet`) | 1 HYPE | 24 hours | API key + 0.08 ETH mainnet | +| QuickNode (`faucet.quicknode.com/hyperliquid/testnet`) | 1 HYPE | 12 hours | 0.05 HYPE mainnet + Tweet | +| Gas.zip (`gas.zip/faucet/hyperevm`) | 0.0025 HYPE | 12 hours | Eligibility check | + +Alternative: Claim USDC from the drip faucet, swap for HYPE on the testnet spot market, then transfer HYPE to HyperEVM using the in-app "Transfer to/from EVM" button. + +## Viewing position details + +### API method +Query the info endpoint at `https://api.hyperliquid-testnet.xyz/info`: + +```json +POST /info +{ + "type": "clearinghouseState", + "user": "0xYourAddress" +} +``` + +The response includes `assetPositions` array with each position's coin, signed size (`szi`), entry price, leverage, unrealized PnL, and liquidation price. + +### SDK methods +Both TypeScript SDKs provide position queries: + +```typescript +// Using 'hyperliquid' package (nomeida) +const state = await sdk.info.perpetuals.getClearinghouseState('0x...'); +const positions = state.assetPositions; + +// Using '@nktkas/hyperliquid' package +const state = await infoClient.clearinghouseState({ user: '0x...' }); +``` + +### Explorer tools +- **Purrsec Testnet**: `https://testnet.purrsec.com/` for HyperEVM transactions +- **Testnet App**: `https://app.hyperliquid-testnet.xyz/portfolio` shows positions directly + +## Technical details for script development + +### SDK installation and testnet configuration + +The `hyperliquid` npm package (by nomeida) provides the most straightforward integration: + +```typescript +import { Hyperliquid } from 'hyperliquid'; + +const sdk = new Hyperliquid({ + privateKey: process.env.PRIVATE_KEY, + testnet: true, // Critical: enables testnet endpoints + enableWs: false // Disable if not using subscriptions +}); + +// Optional: wait for asset maps to initialize +await sdk.connect(); +``` + +For the `@nktkas/hyperliquid` package: + +```typescript +import * as hl from '@nktkas/hyperliquid'; + +const transport = new hl.HttpTransport({ isTestnet: true }); +const infoClient = new hl.InfoClient({ transport }); +const exchClient = new hl.ExchangeClient({ + wallet: process.env.PRIVATE_KEY, + transport +}); +``` + +### API endpoints for testnet + +| Endpoint | URL | +|----------|-----| +| Info API | `https://api.hyperliquid-testnet.xyz/info` | +| Exchange API | `https://api.hyperliquid-testnet.xyz/exchange` | +| WebSocket | `wss://api.hyperliquid-testnet.xyz/ws` | +| EVM RPC | `https://rpc.hyperliquid-testnet.xyz/evm` | + +### Order parameters for perpetuals + +Asset identification uses integer indices from the `meta` response. **BTC is typically index 0**, ETH is 1. The SDK abstracts this with strings like `'BTC-PERP'`. + +```typescript +// Required order parameters +{ + coin: 'BTC-PERP', // Market identifier + is_buy: true, // Direction: true=long, false=short + sz: 0.01, // Size in base asset + limit_px: 95000, // Limit price + order_type: { + limit: { tif: 'Gtc' } // Gtc, Ioc, or Alo (post-only) + }, + reduce_only: false // Whether order only reduces position +} +``` + +For leverage, call `updateLeverage` before or after opening positions: + +```typescript +await sdk.exchange.updateLeverage({ + coin: 'BTC-PERP', + leverageMode: 'cross', // 'cross' or 'isolated' + leverage: 10 +}); +``` + +### Adding margin to positions + +For isolated margin positions, use `updateIsolatedMargin`: + +```typescript +// Raw API action structure +{ + "type": "updateIsolatedMargin", + "asset": 0, // Asset index (0 = BTC) + "isBuy": true, // Position direction + "ntli": 1000000 // Amount in 6 decimals (1000000 = 1 USDC) +} + +// Alternative: target specific leverage +{ + "type": "topUpIsolatedOnlyMargin", + "asset": 0, + "leverage": "5.0" // Target leverage as float string +} +``` + +The SDKs don't always expose this directly—you may need to construct the raw action and post to the exchange endpoint. + +### EIP-712 signing structure + +Hyperliquid uses two signing patterns. **L1 actions** (orders, leverage updates) use a phantom agent mechanism: + +```typescript +const domain = { + name: "Exchange", + version: "1", + chainId: 1337, // Fixed phantom chain ID + verifyingContract: "0x0000000000000000000000000000000000000000" +}; + +const types = { + Agent: [ + { name: "source", type: "string" }, + { name: "connectionId", type: "bytes32" } + ] +}; + +// message.source = "b" for testnet, "a" for mainnet +``` + +**User-signed actions** (transfers, withdrawals) use: + +```typescript +const domain = { + name: "HyperliquidSignTransaction", + version: "1", + chainId: 421614, // Arbitrum Sepolia for testnet + verifyingContract: "0x0000000000000000000000000000000000000000" +}; + +// message.hyperliquidChain = "Testnet" +``` + +The SDKs handle all signing internally—you simply provide a private key or ethers/viem wallet. + +### Rate limits and restrictions + +**IP-based limits**: +- REST: **1,200 weight per minute** (most requests = 2 weight, some = 20) +- EVM RPC: **100 requests/minute** for public endpoint +- WebSocket: Max 1,000 subscriptions per IP + +**Address-based limits**: +- Initial buffer of 10,000 requests +- Replenished at 1 request per $1 USDC traded +- When rate limited: 1 request per 10 seconds + +**Minimum requirements**: +- $10 minimum notional per order +- Account must have deposited **at least 5 USDC** before API access works + +### Nonce handling + +Use current timestamp in milliseconds. The system stores the 100 highest nonces per signing key and accepts nonces within (T - 2 days, T + 1 day) of block timestamp. + +```typescript +const nonce = Date.now(); +``` + +## Example TypeScript scripts + +### Script 1: Open a perpetual position + +```typescript +// openPosition.ts +import { Hyperliquid } from 'hyperliquid'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +async function openPosition() { + const sdk = new Hyperliquid({ + privateKey: process.env.PRIVATE_KEY!, + testnet: true, + enableWs: false + }); + + // Set leverage first (optional, defaults to 1x cross) + await sdk.exchange.updateLeverage({ + coin: 'BTC-PERP', + leverageMode: 'cross', + leverage: 5 + }); + + // Get current mid price for reference + const mids = await sdk.info.getAllMids(); + const btcMid = parseFloat(mids['BTC']); + + // Place a limit order slightly below market for a long + const result = await sdk.exchange.placeOrder({ + coin: 'BTC-PERP', + is_buy: true, + sz: 0.001, // Minimum size ~$95 at $95k + limit_px: Math.floor(btcMid * 0.999), // 0.1% below mid + order_type: { limit: { tif: 'Gtc' } }, + reduce_only: false + }); + + console.log('Order result:', JSON.stringify(result, null, 2)); + + // Check position + const state = await sdk.info.perpetuals.getClearinghouseState( + sdk.walletAddress + ); + console.log('Positions:', state.assetPositions); +} + +openPosition().catch(console.error); +``` + +### Script 2: Add margin to position + +```typescript +// addMargin.ts +import * as dotenv from 'dotenv'; +dotenv.config(); + +const TESTNET_EXCHANGE_URL = 'https://api.hyperliquid-testnet.xyz/exchange'; + +async function addMarginToPosition() { + const { ethers } = await import('ethers'); + + const wallet = new ethers.Wallet(process.env.PRIVATE_KEY!); + const address = wallet.address; + + // EIP-712 domain for L1 actions + const domain = { + name: 'Exchange', + version: '1', + chainId: 1337, + verifyingContract: '0x0000000000000000000000000000000000000000' + }; + + const types = { + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' } + ] + }; + + // Action to add margin + const action = { + type: 'updateIsolatedMargin', + asset: 0, // BTC index + isBuy: true, // Long position + ntli: 10000000 // 10 USDC (6 decimals) + }; + + const nonce = Date.now(); + + // Hash the action for signing + const actionHash = ethers.keccak256( + ethers.toUtf8Bytes(JSON.stringify(action) + nonce) + ); + + const message = { + source: 'b', // 'b' for testnet, 'a' for mainnet + connectionId: actionHash + }; + + const signature = await wallet.signTypedData(domain, types, message); + const { r, s, v } = ethers.Signature.from(signature); + + const payload = { + action, + nonce, + signature: { r, s, v } + }; + + const response = await fetch(TESTNET_EXCHANGE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const result = await response.json(); + console.log('Add margin result:', JSON.stringify(result, null, 2)); +} + +addMarginToPosition().catch(console.error); +``` + +**Note**: The `hyperliquid` SDK may not expose `updateIsolatedMargin` directly. The second script shows raw API interaction. For production use, consider wrapping the signing logic or using the `@nktkas/hyperliquid` SDK's `exchClient.updateIsolatedMargin()` method. + +## Additional technical considerations + +**Testnet quirks**: Testnet data may have lower liquidity and wider spreads than mainnet. The drip faucet requirement for mainnet activity catches some developers off-guard—fund your mainnet wallet first. + +**No official @hyperliquid/sdk**: Despite the task mentioning `@hyperliquid/sdk`, Hyperliquid maintains only a Python SDK officially. The TypeScript SDKs (`hyperliquid` by nomeida, `@nktkas/hyperliquid`) are community-maintained but well-documented and actively updated. + +**Authentication**: No API keys required—authentication is entirely through EIP-712 signatures from your wallet. This means your private key must be available to sign every exchange action. + +**Contract addresses**: There are no separate perp trading contracts on HyperEVM. Perp trading happens on HyperCore through the API. HyperEVM contracts interact with HyperCore via the CoreWriter system contract at `0x3333333333333333333333333333333333333333`. + +**Error patterns**: Common errors include `"User does not exist"` (need to deposit 5+ USDC first), signature mismatches (check testnet vs mainnet domain), and rate limit exceeded (implement exponential backoff). + +## Conclusion + +HyperEVM testnet provides a complete environment for developing perpetual trading applications. The dual architecture—HyperCore for native trading, HyperEVM for programmable DeFi—enables novel applications like automated liquidators, delta-hedging vaults, and composable margin protocols. Start with the drip faucet at `app.hyperliquid-testnet.xyz/drip`, configure the TypeScript SDK with `testnet: true`, and leverage the `clearinghouseState` endpoint to monitor positions throughout development. \ No newline at end of file diff --git a/packages/nextjs/app/settle/[requestId]/page.tsx b/packages/nextjs/app/settle/[requestId]/page.tsx new file mode 100644 index 0000000..83f2e63 --- /dev/null +++ b/packages/nextjs/app/settle/[requestId]/page.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { AlertCircle, CheckCircle, Clock, Loader2, Wallet } from "lucide-react"; +import { useAccount } from "wagmi"; +import { SettleModal, usePaymentRequest } from "~~/components/settle"; + +interface SettleRequestPageProps { + params: { + requestId: string; + }; +} + +export default function SettleRequestPage({ params }: SettleRequestPageProps) { + const router = useRouter(); + const { address, isConnected } = useAccount(); + const { paymentParams, isLoading, error, isExpired, isCompleted, isWrongWallet, markAsCompleted } = usePaymentRequest( + params.requestId, + ); + + const handleClose = () => { + router.push("/"); + }; + + const handleSuccess = async (txHash: string) => { + // Mark the request as completed in the database + await markAsCompleted(txHash); + + // Redirect after a short delay to show success state + setTimeout(() => { + router.push("/"); + }, 2000); + }; + + // Loading state + if (isLoading) { + return ( +
+
+ +

Loading payment request...

+
+
+ ); + } + + // Error state + if (error) { + return ( +
+
+
+ +
+

Request Not Found

+

{error}

+ +
+
+ ); + } + + // Expired state + if (isExpired) { + return ( +
+
+
+ +
+

Request Expired

+

This payment request has expired. Please ask for a new link.

+ +
+
+ ); + } + + // Already completed state + if (isCompleted) { + return ( +
+
+
+ +
+

Already Paid

+

This payment request has already been completed.

+ +
+
+ ); + } + + // Not connected state + if (!isConnected) { + return ( +
+
+
+ +
+

Connect Wallet

+

Please connect your wallet to complete this payment.

+
+
+ ); + } + + // Wrong wallet state + if (isWrongWallet) { + return ( +
+
+
+ +
+

Wrong Wallet

+

This payment request is for a different wallet.

+

+ Expected: {paymentParams?.recipient.slice(0, 6)}...{paymentParams?.recipient.slice(-4)} +

+

+ Connected: {address?.slice(0, 6)}...{address?.slice(-4)} +

+ +
+
+ ); + } + + // Show settle modal if we have valid params + if (!paymentParams) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/packages/nextjs/components/TopNav.tsx b/packages/nextjs/components/TopNav.tsx index 6c082d3..ba326db 100644 --- a/packages/nextjs/components/TopNav.tsx +++ b/packages/nextjs/components/TopNav.tsx @@ -57,8 +57,8 @@ export const TopNav = () => {
{/* Logo - Unified segmented control */} - Split - hub + Split + hub {/* Wallet Section */} diff --git a/packages/nextjs/components/settle/SettleFlow.tsx b/packages/nextjs/components/settle/SettleFlow.tsx new file mode 100644 index 0000000..35a401f --- /dev/null +++ b/packages/nextjs/components/settle/SettleFlow.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useSettleFlow } from "./hooks/useSettleFlow"; +import { FLOW_STEPS, SettleFlowProps } from "./types"; +import { AlertCircle, Check, Coins, Fuel, Loader2, Nfc, User, Wallet } from "lucide-react"; +import { useTargetNetwork } from "~~/hooks/scaffold-eth"; + +export function SettleFlow({ params, onSuccess, onError }: SettleFlowProps) { + const { targetNetwork } = useTargetNetwork(); + const { + flowState, + statusMessage, + error, + txHash, + symbol, + isConnected, + paymentsAddress, + handleSettle, + reset, + getCurrentStepIndex, + } = useSettleFlow({ params, onSuccess, onError }); + + const isProcessing = ["tapping", "signing", "submitting", "confirming"].includes(flowState); + + if (!isConnected) { + return ( +
+
+ +
+

Connect your wallet to settle

+
+ ); + } + + if (flowState === "success") { + return ( +
+
+ +
+

Payment Complete

+ + {/* Amount sent */} +
+ + + {params.amount} {symbol || "tokens"} sent + +
+ + {/* Transaction hash */} + {txHash && ( + + View transaction → + + )} + + +
+ ); + } + + if (isProcessing) { + return ( +
+ {/* Progress Steps */} +
+ {FLOW_STEPS.map((step, idx) => { + const currentIdx = getCurrentStepIndex(); + const isComplete = idx < currentIdx; + const isCurrent = idx === currentIdx; + return ( +
+
+ {isComplete ? : idx + 1} +
+ {idx < FLOW_STEPS.length - 1 && ( +
+ )} +
+ ); + })} +
+ + {/* Animated Processing Indicator */} +
+
+ +
+ {flowState === "tapping" && ( + <> +
+
+ + )} +
+ +

{statusMessage}

+

+ {flowState === "tapping" && "Hold device near chip"} + {flowState === "signing" && "Authorizing payment"} + {flowState === "submitting" && "Broadcasting to network"} + {flowState === "confirming" && "Waiting for confirmation"} +

+
+ ); + } + + // Idle State - Main Payment UI + return ( +
+ {/* Info Pills */} +
+ {/* Recipient Pill */} +
+ + + {params.recipient.slice(0, 6)}...{params.recipient.slice(-4)} + +
+ + {/* Token Pill */} +
+ + {symbol || "Token"} + +
+ + {/* Gasless Pill */} +
+ + Gasless +
+
+ + {/* Memo if present */} + {params.memo &&

{params.memo}

} + + {/* Amount Display */} +
+

{params.amount}

+

{symbol || "tokens"}

+
+ + {/* Error Message */} + {error && ( +
+ + {error} +
+ )} + + {/* 3D NFC Chip Button */} +
+ {/* Pulse rings */} +
+
+
+ + +
+
+ ); +} diff --git a/packages/nextjs/components/settle/SettleModal.tsx b/packages/nextjs/components/settle/SettleModal.tsx new file mode 100644 index 0000000..3cfd679 --- /dev/null +++ b/packages/nextjs/components/settle/SettleModal.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import { SettleFlow } from "./SettleFlow"; +import { SettleModalProps } from "./types"; +import { X } from "lucide-react"; + +export function SettleModal({ isOpen, onClose, params, onSuccess, onError }: SettleModalProps) { + // Handle escape key to close modal + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener("keydown", handleEscape); + // Prevent body scroll when modal is open + document.body.style.overflow = "hidden"; + } + + return () => { + document.removeEventListener("keydown", handleEscape); + document.body.style.overflow = "unset"; + }; + }, [isOpen, onClose]); + + // Handle success with auto-dismiss + const handleSuccess = useCallback( + (txHash: string) => { + // Call parent success handler + if (onSuccess) { + onSuccess(txHash); + } + + // Auto-dismiss after 2 seconds + setTimeout(() => { + onClose(); + }, 2000); + }, + [onSuccess, onClose], + ); + + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal Container */} +
+ {/* Close Button */} + + + {/* Header */} +
+

Payment Request

+ {params.memo &&

{params.memo}

} +
+ + {/* Content */} +
+ +
+
+
+ ); +} diff --git a/packages/nextjs/components/settle/hooks/useMultiSettleFlow.ts b/packages/nextjs/components/settle/hooks/useMultiSettleFlow.ts new file mode 100644 index 0000000..ae2325d --- /dev/null +++ b/packages/nextjs/components/settle/hooks/useMultiSettleFlow.ts @@ -0,0 +1,400 @@ +import { useCallback, useMemo, useState } from "react"; +import { BatchPaymentAuth, ERC20_ABI, Participant, SPLIT_HUB_PAYMENTS_ABI, SPLIT_HUB_REGISTRY_ABI } from "../types"; +import { createPublicClient, http, parseUnits } from "viem"; +import { useAccount, useReadContract } from "wagmi"; +import deployedContracts from "~~/contracts/deployedContracts"; +import { useHaloChip } from "~~/hooks/halochip-arx/useHaloChip"; +import { useTargetNetwork } from "~~/hooks/scaffold-eth"; + +type MultiFlowState = "collecting" | "submitting" | "confirming" | "success" | "error"; + +interface UseMultiSettleFlowOptions { + recipient: `0x${string}`; + token: `0x${string}`; + amounts: string[]; + onSuccess?: (txHash: string) => void; + onError?: (error: Error) => void; +} + +interface UseMultiSettleFlowReturn { + flowState: MultiFlowState; + participants: Participant[]; + currentSigningIndex: number | null; + allSigned: boolean; + signedCount: number; + totalCount: number; + error: string; + txHash: string | null; + symbol: string | undefined; + decimals: number | undefined; + isConnected: boolean; + paymentsAddress: `0x${string}` | undefined; + totalAmount: string; + signSlot: (slotIndex: number) => Promise; + submitBatch: () => Promise; + reset: () => void; +} + +export function useMultiSettleFlow({ + recipient, + token, + amounts, + onSuccess, + onError, +}: UseMultiSettleFlowOptions): UseMultiSettleFlowReturn { + const { isConnected } = useAccount(); + const { targetNetwork } = useTargetNetwork(); + const { signTypedData } = useHaloChip(); + + // Initialize participants from amounts (payers will be auto-detected) + const [participants, setParticipants] = useState(() => + amounts.map((amount, idx) => ({ + id: `slot-${idx}`, + expectedAmount: amount, + status: "waiting" as const, + })), + ); + + const [flowState, setFlowState] = useState("collecting"); + const [currentSigningIndex, setCurrentSigningIndex] = useState(null); + const [error, setError] = useState(""); + const [txHash, setTxHash] = useState(null); + + // Get contract addresses + const chainContracts = deployedContracts[targetNetwork.id as keyof typeof deployedContracts] as + | Record + | undefined; + const paymentsAddress = chainContracts?.SplitHubPayments?.address as `0x${string}` | undefined; + const registryAddress = chainContracts?.SplitHubRegistry?.address as `0x${string}` | undefined; + + // Read token info + const { data: decimals } = useReadContract({ + address: token, + abi: ERC20_ABI, + functionName: "decimals", + }); + + const { data: symbol } = useReadContract({ + address: token, + abi: ERC20_ABI, + functionName: "symbol", + }); + + // Create public client for contract reads + const publicClient = useMemo( + () => + createPublicClient({ + chain: targetNetwork, + transport: http(), + }), + [targetNetwork], + ); + + // Calculate totals + const signedCount = participants.filter(p => p.status === "signed").length; + const totalCount = participants.length; + const allSigned = signedCount === totalCount; + const totalAmount = amounts.reduce((sum, a) => sum + parseFloat(a), 0).toString(); + + // Sign a slot - auto-detects payer from chip tap + const signSlot = useCallback( + async (slotIndex: number) => { + if (slotIndex < 0 || slotIndex >= participants.length) return; + + const participant = participants[slotIndex]; + + if (!isConnected) { + setError("Please connect your wallet first"); + return; + } + + if (!paymentsAddress || !registryAddress) { + setError("Contracts not deployed on this network"); + return; + } + + if (decimals === undefined) { + setError("Could not read token decimals"); + return; + } + + // Update status to signing + setCurrentSigningIndex(slotIndex); + setParticipants(prev => + prev.map((p, idx) => (idx === slotIndex ? { ...p, status: "signing" as const, error: undefined } : p)), + ); + + try { + // Step 1: First, we need to do a preliminary tap to get the chip address + // We'll create a dummy message to sign just to get the chip address + // Then look up the owner, build the real PaymentAuth, and sign again + + // Actually, let's think about this differently: + // The chip signs the PaymentAuth which includes `payer`. + // But we don't know the payer until we tap! + // + // Solution: We need to do the chip tap first with a placeholder, + // extract the chipAddress from the result, look up owner, + // then build the correct PaymentAuth and sign again. + // + // OR: We can use the chip address as a way to look up owner first, + // by doing a simple message sign to get the address. + + // Let's do a simple approach: sign a dummy message first to get chip address + const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); + const amountInWei = parseUnits(participant.expectedAmount, decimals); + + // First tap: Get chip address (we'll use a preliminary sign) + // Actually, looking at the signTypedData, it returns { address, signature } + // The address IS the chip address. So we need to sign something first. + + // For now, let's sign with payer=0x0 just to get the chip address, + // then we'll do the real sign with the correct payer + // This is a bit hacky but works for the PoC + + // Alternative: Use signMessage to just get the address first + // But that requires another tap... + + // Best approach: Sign the PaymentAuth with a placeholder payer, + // extract chipAddress, look up owner, verify it matches expectations, + // then we already have a valid signature because: + // - The signature is made by the chip + // - The contract verifies: registry.ownerOf(signer) == auth.payer + // - So if we set payer = owner of the chip that signed, it will work! + + // Actually wait - the problem is the payer is INSIDE the message being signed. + // So if we sign with wrong payer, the signature is for wrong message. + + // Real solution: We need TWO taps or use signMessage first. + // Let's use the simpler approach: sign with a placeholder, get chip, + // look up owner, then sign AGAIN with correct payer. + + // For better UX, let's do signMessage first to get chipAddress: + // NO - useHaloChip only has signTypedData exposed properly. + + // Let's just do two signs. First with placeholder to get chip address: + const placeholderAuth = { + payer: "0x0000000000000000000000000000000000000000" as `0x${string}`, + recipient, + token, + amount: amountInWei, + nonce: BigInt(0), + deadline, + }; + + const domain = { + name: "SplitHubPayments", + version: "1", + chainId: BigInt(targetNetwork.id), + verifyingContract: paymentsAddress, + }; + + const types = { + PaymentAuth: [ + { name: "payer", type: "address" }, + { name: "recipient", type: "address" }, + { name: "token", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }; + + // First tap to get chip address + const firstTapResult = await signTypedData({ + domain, + types, + primaryType: "PaymentAuth", + message: placeholderAuth, + }); + + const chipAddress = firstTapResult.address as `0x${string}`; + + // Look up owner from registry + const owner = (await publicClient.readContract({ + address: registryAddress, + abi: SPLIT_HUB_REGISTRY_ABI, + functionName: "ownerOf", + args: [chipAddress], + })) as `0x${string}`; + + if (!owner || owner === "0x0000000000000000000000000000000000000000") { + throw new Error("Chip not registered. Please register your chip first."); + } + + // Now get the correct nonce for this payer + const nonce = (await publicClient.readContract({ + address: paymentsAddress, + abi: SPLIT_HUB_PAYMENTS_ABI, + functionName: "nonces", + args: [owner], + })) as bigint; + + // Build the REAL PaymentAuth with correct payer + const realPaymentAuth = { + payer: owner, + recipient, + token, + amount: amountInWei, + nonce, + deadline, + }; + + // Second tap to sign the real message + const realTapResult = await signTypedData({ + domain, + types, + primaryType: "PaymentAuth", + message: realPaymentAuth, + }); + + // Verify same chip signed + if (realTapResult.address !== chipAddress) { + throw new Error("Different chip used for second tap. Please use the same chip."); + } + + // Update participant with all the info (including deadline for later submission) + setParticipants(prev => + prev.map((p, idx) => + idx === slotIndex + ? { + ...p, + status: "signed" as const, + chipAddress, + payer: owner, + signature: realTapResult.signature, + nonce, + deadline, + } + : p, + ), + ); + } catch (err: any) { + console.error("Signing error:", err); + setParticipants(prev => + prev.map((p, idx) => + idx === slotIndex + ? { + ...p, + status: "error" as const, + error: err.message || "Signing failed", + } + : p, + ), + ); + } finally { + setCurrentSigningIndex(null); + } + }, + [ + participants, + isConnected, + paymentsAddress, + registryAddress, + decimals, + recipient, + token, + targetNetwork.id, + signTypedData, + publicClient, + ], + ); + + // Submit all signed payments as a batch + const submitBatch = useCallback(async () => { + if (!allSigned) { + setError("Not all participants have signed"); + return; + } + + if (!paymentsAddress || decimals === undefined) { + setError("Contract not ready"); + return; + } + + setFlowState("submitting"); + setError(""); + + try { + // Build batch payload using the deadline that was signed (not a new one!) + const batchAuths: BatchPaymentAuth[] = participants.map(p => ({ + payer: p.payer!, + recipient, + token, + amount: parseUnits(p.expectedAmount, decimals).toString(), + nonce: p.nonce!.toString(), + deadline: p.deadline!.toString(), + signature: p.signature!, + })); + + // Submit to batch relay + const response = await fetch("/api/relay/batch-payment", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + payments: batchAuths, + contractAddress: paymentsAddress, + }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || "Batch relay failed"); + } + + setFlowState("confirming"); + setTxHash(result.txHash); + + // Brief delay then success + await new Promise(resolve => setTimeout(resolve, 1000)); + + setFlowState("success"); + + if (onSuccess) { + onSuccess(result.txHash); + } + } catch (err: any) { + console.error("Batch submit error:", err); + setFlowState("error"); + setError(err.message || "Batch submission failed"); + + if (onError) { + onError(err); + } + } + }, [allSigned, participants, paymentsAddress, decimals, recipient, token, onSuccess, onError]); + + const reset = useCallback(() => { + setParticipants( + amounts.map((amount, idx) => ({ + id: `slot-${idx}`, + expectedAmount: amount, + status: "waiting" as const, + })), + ); + setFlowState("collecting"); + setCurrentSigningIndex(null); + setError(""); + setTxHash(null); + }, [amounts]); + + return { + flowState, + participants, + currentSigningIndex, + allSigned, + signedCount, + totalCount, + error, + txHash, + symbol, + decimals, + isConnected, + paymentsAddress, + totalAmount, + signSlot, + submitBatch, + reset, + }; +} diff --git a/packages/nextjs/components/settle/hooks/usePaymentRequest.ts b/packages/nextjs/components/settle/hooks/usePaymentRequest.ts new file mode 100644 index 0000000..f91ffba --- /dev/null +++ b/packages/nextjs/components/settle/hooks/usePaymentRequest.ts @@ -0,0 +1,123 @@ +import { useEffect, useState } from "react"; +import { PaymentParams, PaymentRequest } from "../types"; +import { useAccount } from "wagmi"; + +interface UsePaymentRequestReturn { + paymentRequest: PaymentRequest | null; + paymentParams: PaymentParams | null; + isLoading: boolean; + error: string | null; + isExpired: boolean; + isCompleted: boolean; + isWrongWallet: boolean; + markAsCompleted: (txHash: string) => Promise; +} + +export function usePaymentRequest(requestId: string | null): UsePaymentRequestReturn { + const { address } = useAccount(); + const [paymentRequest, setPaymentRequest] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!requestId) { + setIsLoading(false); + setError("No request ID provided"); + return; + } + + const fetchRequest = async () => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`/api/payment-requests/${requestId}`); + + if (!response.ok) { + if (response.status === 404) { + throw new Error("Payment request not found"); + } + if (response.status === 410) { + throw new Error("Payment request has expired"); + } + throw new Error("Failed to fetch payment request"); + } + + const data = await response.json(); + setPaymentRequest(data); + } catch (err: any) { + setError(err.message || "Failed to load payment request"); + } finally { + setIsLoading(false); + } + }; + + fetchRequest(); + }, [requestId]); + + // Convert PaymentRequest to PaymentParams for SettleFlow + const paymentParams: PaymentParams | null = paymentRequest + ? { + recipient: paymentRequest.recipient as `0x${string}`, + token: paymentRequest.token as `0x${string}`, + amount: paymentRequest.amount, + memo: paymentRequest.memo || undefined, + } + : null; + + // Check if request is expired + const isExpired = paymentRequest ? new Date(paymentRequest.expires_at) < new Date() : false; + + // Check if request is already completed + const isCompleted = paymentRequest?.status === "completed"; + + // Check if connected wallet matches the expected payer + const isWrongWallet = + paymentRequest && address ? paymentRequest.payer.toLowerCase() !== address.toLowerCase() : false; + + // Function to mark request as completed + const markAsCompleted = async (txHash: string) => { + if (!requestId) return; + + try { + const response = await fetch(`/api/payment-requests/${requestId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + status: "completed", + tx_hash: txHash, + }), + }); + + if (!response.ok) { + throw new Error("Failed to update payment request"); + } + + // Update local state + setPaymentRequest(prev => + prev + ? { + ...prev, + status: "completed", + tx_hash: txHash, + } + : null, + ); + } catch (err) { + console.error("Failed to mark payment as completed:", err); + } + }; + + return { + paymentRequest, + paymentParams, + isLoading, + error, + isExpired, + isCompleted, + isWrongWallet, + markAsCompleted, + }; +} diff --git a/packages/nextjs/components/settle/hooks/useSettleFlow.ts b/packages/nextjs/components/settle/hooks/useSettleFlow.ts new file mode 100644 index 0000000..73d43df --- /dev/null +++ b/packages/nextjs/components/settle/hooks/useSettleFlow.ts @@ -0,0 +1,245 @@ +import { useCallback, useState } from "react"; +import { ERC20_ABI, FlowState, PaymentParams, SPLIT_HUB_PAYMENTS_ABI } from "../types"; +import { parseUnits } from "viem"; +import { useAccount, useReadContract } from "wagmi"; +import deployedContracts from "~~/contracts/deployedContracts"; +import { useHaloChip } from "~~/hooks/halochip-arx/useHaloChip"; +import { useTargetNetwork } from "~~/hooks/scaffold-eth"; + +interface UseSettleFlowOptions { + params: PaymentParams; + onSuccess?: (txHash: string) => void; + onError?: (error: Error) => void; +} + +interface UseSettleFlowReturn { + flowState: FlowState; + statusMessage: string; + error: string; + txHash: string | null; + symbol: string | undefined; + decimals: number | undefined; + isConnected: boolean; + paymentsAddress: `0x${string}` | undefined; + handleSettle: () => Promise; + reset: () => void; + getCurrentStepIndex: () => number; +} + +export function useSettleFlow({ params, onSuccess, onError }: UseSettleFlowOptions): UseSettleFlowReturn { + const { address, isConnected } = useAccount(); + const { targetNetwork } = useTargetNetwork(); + const { signTypedData } = useHaloChip(); + + const [flowState, setFlowState] = useState("idle"); + const [statusMessage, setStatusMessage] = useState(""); + const [error, setError] = useState(""); + const [txHash, setTxHash] = useState(null); + + // Get SplitHubPayments contract address for the current network + const chainContracts = deployedContracts[targetNetwork.id as keyof typeof deployedContracts] as + | Record + | undefined; + const paymentsAddress = chainContracts?.SplitHubPayments?.address as `0x${string}` | undefined; + + // Read token decimals + const { data: decimals } = useReadContract({ + address: params.token, + abi: ERC20_ABI, + functionName: "decimals", + }); + + // Read token symbol + const { data: symbol } = useReadContract({ + address: params.token, + abi: ERC20_ABI, + functionName: "symbol", + }); + + // Read current nonce for payer + const { data: currentNonce, refetch: refetchNonce } = useReadContract({ + address: paymentsAddress, + abi: SPLIT_HUB_PAYMENTS_ABI, + functionName: "nonces", + args: address ? [address] : undefined, + query: { + enabled: !!address && !!paymentsAddress, + }, + }); + + const handleSettle = useCallback(async () => { + setError(""); + setTxHash(null); + + if (!isConnected || !address) { + setError("Please connect your wallet first"); + return; + } + + if (!paymentsAddress) { + setError("SplitHubPayments contract not deployed on this network"); + return; + } + + if (decimals === undefined) { + setError("Could not read token decimals. Is this a valid ERC-20 token?"); + return; + } + + if (currentNonce === undefined) { + setError("Could not read nonce from contract"); + return; + } + + try { + setFlowState("tapping"); + setStatusMessage("Tap your chip"); + + // Build PaymentAuth struct + const amountInWei = parseUnits(params.amount, decimals); + const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); // 1 hour from now + + const paymentAuth = { + payer: address, + recipient: params.recipient, + token: params.token, + amount: amountInWei, + nonce: currentNonce, + deadline: deadline, + }; + + // EIP-712 domain and types matching SplitHubPayments.sol + const domain = { + name: "SplitHubPayments", + version: "1", + chainId: BigInt(targetNetwork.id), + verifyingContract: paymentsAddress, + }; + + const types = { + PaymentAuth: [ + { name: "payer", type: "address" }, + { name: "recipient", type: "address" }, + { name: "token", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }; + + // Signing state + setFlowState("signing"); + setStatusMessage("Signing..."); + + // Sign with NFC chip + const chipResult = await signTypedData({ + domain, + types, + primaryType: "PaymentAuth", + message: paymentAuth, + }); + + // Submitting state + setFlowState("submitting"); + setStatusMessage("Sending..."); + + // Submit to relay API + const response = await fetch("/api/relay/payment", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + auth: { + payer: paymentAuth.payer, + recipient: paymentAuth.recipient, + token: paymentAuth.token, + amount: paymentAuth.amount.toString(), + nonce: paymentAuth.nonce.toString(), + deadline: paymentAuth.deadline.toString(), + }, + signature: chipResult.signature, + contractAddress: paymentsAddress, + }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || "Relay request failed"); + } + + // Confirming state + setFlowState("confirming"); + setStatusMessage("Confirming..."); + setTxHash(result.txHash); + + // Brief delay to show confirming state + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Refetch nonce for next payment + await refetchNonce(); + + setFlowState("success"); + setStatusMessage("Complete!"); + + // Call success callback + if (onSuccess) { + onSuccess(result.txHash); + } + } catch (err: any) { + console.error("Settlement error:", err); + setFlowState("error"); + setError(err.message || "Settlement failed. Please try again."); + setStatusMessage(""); + + // Call error callback + if (onError) { + onError(err); + } + } + }, [ + address, + isConnected, + paymentsAddress, + decimals, + currentNonce, + params, + targetNetwork.id, + signTypedData, + refetchNonce, + onSuccess, + onError, + ]); + + const reset = useCallback(() => { + setFlowState("idle"); + setError(""); + setStatusMessage(""); + setTxHash(null); + }, []); + + const getCurrentStepIndex = useCallback(() => { + const stepMap: Record = { + tapping: 0, + signing: 1, + submitting: 2, + confirming: 3, + }; + return stepMap[flowState] ?? -1; + }, [flowState]); + + return { + flowState, + statusMessage, + error, + txHash, + symbol, + decimals, + isConnected, + paymentsAddress, + handleSettle, + reset, + getCurrentStepIndex, + }; +} diff --git a/packages/nextjs/components/settle/index.ts b/packages/nextjs/components/settle/index.ts new file mode 100644 index 0000000..2a4d19b --- /dev/null +++ b/packages/nextjs/components/settle/index.ts @@ -0,0 +1,22 @@ +// Components +export { SettleFlow } from "./SettleFlow"; +export { SettleModal } from "./SettleModal"; +export { MultiSettleFlow } from "./MultiSettleFlow"; + +// Hooks +export { useSettleFlow } from "./hooks/useSettleFlow"; +export { usePaymentRequest } from "./hooks/usePaymentRequest"; +export { useMultiSettleFlow } from "./hooks/useMultiSettleFlow"; + +// Types +export type { + PaymentParams, + PaymentRequest, + SettleFlowProps, + SettleModalProps, + FlowState, + CreatePaymentRequestResponse, + MultiSettleFlowProps, + Participant, + BatchPaymentAuth, +} from "./types"; diff --git a/packages/nextjs/components/settle/types.ts b/packages/nextjs/components/settle/types.ts new file mode 100644 index 0000000..0ce810f --- /dev/null +++ b/packages/nextjs/components/settle/types.ts @@ -0,0 +1,127 @@ +// Payment parameters passed to the settle component +export interface PaymentParams { + recipient: `0x${string}`; + token: `0x${string}`; + amount: string; + memo?: string; +} + +// Flow states for the settle process +export type FlowState = "idle" | "tapping" | "signing" | "submitting" | "confirming" | "success" | "error"; + +// Props for the main SettleFlow component +export interface SettleFlowProps { + params: PaymentParams; + onSuccess?: (txHash: string) => void; + onError?: (error: Error) => void; +} + +// Props for the modal wrapper +export interface SettleModalProps { + isOpen: boolean; + onClose: () => void; + params: PaymentParams; + onSuccess?: (txHash: string) => void; + onError?: (error: Error) => void; +} + +// Payment request stored in database +export interface PaymentRequest { + id: string; + payer: string; + recipient: string; + token: string; + amount: string; + memo: string | null; + status: "pending" | "completed" | "expired"; + tx_hash: string | null; + expires_at: string; + created_at: string; +} + +// API response for creating a payment request +export interface CreatePaymentRequestResponse { + requestId: string; + settleUrl: string; +} + +// Multi-settle participant (payer auto-detected from chip tap) +export interface Participant { + id: string; + expectedAmount: string; + status: "waiting" | "signing" | "signed" | "error"; + // Auto-filled after chip tap: + chipAddress?: `0x${string}`; + payer?: `0x${string}`; + signature?: string; + nonce?: bigint; + deadline?: bigint; + error?: string; +} + +// Multi-settle flow props (amounts only - payers are auto-detected) +export interface MultiSettleFlowProps { + recipient: `0x${string}`; + token: `0x${string}`; + amounts: string[]; + memo?: string; + onSuccess?: (txHash: string) => void; + onError?: (error: Error) => void; +} + +// Batch payment auth for relay +export interface BatchPaymentAuth { + payer: `0x${string}`; + recipient: `0x${string}`; + token: `0x${string}`; + amount: string; + nonce: string; + deadline: string; + signature: string; +} + +// Progress steps for visual indicator +export const FLOW_STEPS = [ + { key: "tapping", label: "Tap" }, + { key: "signing", label: "Sign" }, + { key: "submitting", label: "Send" }, + { key: "confirming", label: "Confirm" }, +] as const; + +// ABIs needed for settle flow +export const ERC20_ABI = [ + { + name: "decimals", + type: "function", + inputs: [], + outputs: [{ type: "uint8" }], + stateMutability: "view", + }, + { + name: "symbol", + type: "function", + inputs: [], + outputs: [{ type: "string" }], + stateMutability: "view", + }, +] as const; + +export const SPLIT_HUB_PAYMENTS_ABI = [ + { + name: "nonces", + type: "function", + inputs: [{ name: "payer", type: "address" }], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, +] as const; + +export const SPLIT_HUB_REGISTRY_ABI = [ + { + name: "ownerOf", + type: "function", + inputs: [{ name: "signer", type: "address" }], + outputs: [{ type: "address" }], + stateMutability: "view", + }, +] as const; diff --git a/packages/nextjs/lib/supabase.ts b/packages/nextjs/lib/supabase.ts index 00d520b..7325c5d 100644 --- a/packages/nextjs/lib/supabase.ts +++ b/packages/nextjs/lib/supabase.ts @@ -17,3 +17,18 @@ export type User = { email: string; created_at: string; }; + +// Payment request table type +export type PaymentRequest = { + id: string; + payer: string; + recipient: string; + token: string; + amount: string; + memo: string | null; + status: "pending" | "completed" | "expired"; + tx_hash: string | null; + expires_at: string; + created_at: string; + completed_at?: string; +}; From 44b3160ff807aa2ed031afcd873e856bd700ce44 Mon Sep 17 00:00:00 2001 From: George Mathew <97821867+0xgeorgemathew@users.noreply.github.com> Date: Sun, 30 Nov 2025 09:11:48 +0530 Subject: [PATCH 7/7] style update btc orange --- packages/nextjs/styles/globals.css | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/nextjs/styles/globals.css b/packages/nextjs/styles/globals.css index bd76004..629ac0f 100644 --- a/packages/nextjs/styles/globals.css +++ b/packages/nextjs/styles/globals.css @@ -18,11 +18,11 @@ @plugin "daisyui/theme" { name: "light"; - --color-primary: #ffa31a; + --color-primary: #f2a900; --color-primary-content: #1b1b1b; --color-secondary: #292929; --color-secondary-content: #ffffff; - --color-accent: #ffa31a; + --color-accent: #f2a900; --color-accent-content: #1b1b1b; --color-neutral: #292929; --color-neutral-content: #ffffff; @@ -30,9 +30,9 @@ --color-base-200: #292929; --color-base-300: #3a3a3a; --color-base-content: #ffffff; - --color-info: #ffa31a; + --color-info: #f2a900; --color-success: #22c55e; - --color-warning: #ffa31a; + --color-warning: #f59e0b; --color-error: #ef4444; --radius-field: 9999rem; @@ -43,11 +43,11 @@ @plugin "daisyui/theme" { name: "dark"; - --color-primary: #ffa31a; + --color-primary: #f2a900; --color-primary-content: #1b1b1b; --color-secondary: #292929; --color-secondary-content: #ffffff; - --color-accent: #ffa31a; + --color-accent: #f2a900; --color-accent-content: #1b1b1b; --color-neutral: #808080; --color-neutral-content: #ffffff; @@ -55,9 +55,9 @@ --color-base-200: #292929; --color-base-300: #3a3a3a; --color-base-content: #ffffff; - --color-info: #ffa31a; + --color-info: #f2a900; --color-success: #22c55e; - --color-warning: #ffa31a; + --color-warning: #f59e0b; --color-error: #ef4444; --radius-field: 9999rem; @@ -128,9 +128,9 @@ width: 160px; height: 160px; border-radius: 50%; - background: linear-gradient(145deg, #ffa31a, #e6921a); + background: linear-gradient(145deg, #f2a900, #d99400); box-shadow: - 0 8px 32px rgba(255, 163, 26, 0.4), + 0 8px 32px rgba(242, 169, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3), inset 0 2px 4px rgba(255, 255, 255, 0.2), inset 0 -2px 4px rgba(0, 0, 0, 0.2); @@ -159,7 +159,7 @@ .nfc-chip-btn:hover:not(:disabled) { box-shadow: - 0 12px 40px rgba(255, 163, 26, 0.5), + 0 12px 40px rgba(242, 169, 0, 0.5), 0 6px 20px rgba(0, 0, 0, 0.35), inset 0 2px 4px rgba(255, 255, 255, 0.2), inset 0 -2px 4px rgba(0, 0, 0, 0.2); @@ -168,7 +168,7 @@ .nfc-chip-btn:active:not(:disabled) { box-shadow: - 0 4px 16px rgba(255, 163, 26, 0.3), + 0 4px 16px rgba(242, 169, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.25), inset 0 2px 8px rgba(0, 0, 0, 0.2); transform: translateY(1px) scale(0.98); @@ -184,7 +184,7 @@ position: absolute; inset: -12px; border-radius: 50%; - border: 2px solid rgba(255, 163, 26, 0.6); + border: 2px solid rgba(242, 169, 0, 0.6); animation: nfc-pulse 2s ease-out infinite; }