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 && (
-
- )}
+
+
+
+ {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 && (
+
)}
-
-
- {/* 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 ? (
+
+ ) : 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 && (
+
+ )}
- {/* 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 && (
-
- )}
-
- {/* Submit Button */}
-
+
+ {/* Gasless Pill */}
+
+
+ Gasless
+
+
+
+ {/* Amount Display */}
+
+
{DEFAULT_AMOUNT}
+
{symbol || "tokens"}
+
+
+ {/* Error Message */}
+ {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 && (
+
+ )}
+
+ {/* 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 && (
+
+ )}
+
+ {/* 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;
}