Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"type": "git",
"url": "https://github.com/OWK50GA/Griffin"
},
"workspaces":[
"workspaces": [
"packages/*"
],
"scripts": {
Expand All @@ -20,7 +20,10 @@
"license": "MIT",
"packageManager": "pnpm@10.24.0",
"devDependencies": {
"typescript": "^5.9.0",
"tsup": "^8.5.0"
"tsup": "^8.5.0",
"typescript": "^5.9.0"
},
"dependencies": {
"dotenv": "^17.4.2"
}
}
1 change: 1 addition & 0 deletions packages/app/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_GRIFFIN_API_URL=http://localhost:3000
12 changes: 12 additions & 0 deletions packages/app/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Griffin — Cross-chain Payments</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
25 changes: 25 additions & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@griffin/app",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"wagmi": "^2.14.16",
"viem": "^2.23.10",
"@tanstack/react-query": "^5.64.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"typescript": "^5.7.3",
"vite": "^6.1.0"
}
}
32 changes: 32 additions & 0 deletions packages/app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { WagmiProvider, createConfig, http } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HASHKEY_TESTNET } from "./config";
import { WalletButton } from "./components/WalletButton";
import { SwapForm } from "./components/SwapForm";
import "./index.css";

const wagmiConfig = createConfig({
chains: [HASHKEY_TESTNET],
transports: { [HASHKEY_TESTNET.id]: http() },
});

const queryClient = new QueryClient();

export default function App() {
return (
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<div className="app">
<header>
<h1>Griffin</h1>
<p className="tagline">Cross-token payments, simplified</p>
<WalletButton />
</header>
<main>
<SwapForm />
</main>
</div>
</QueryClientProvider>
</WagmiProvider>
);
}
14 changes: 14 additions & 0 deletions packages/app/src/components/IntentStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { IntentState } from "../hooks/useIntent";

export function IntentStatus({ state, onReset }: { state: IntentState; onReset: () => void }) {
const success = state.status === "completed";
return (
<div className={`intent-result ${success ? "success" : "failure"}`}>
<div className="result-icon">{success ? "✅" : "❌"}</div>
<h3>{success ? "Payment sent!" : "Payment failed"}</h3>
{state.intentId && <p className="intent-id">Intent: {state.intentId}</p>}
{state.error && <p className="error-msg">{state.error}</p>}
<button onClick={onReset}>New payment</button>
</div>
);
}
99 changes: 99 additions & 0 deletions packages/app/src/components/SwapForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useState } from "react";
import { useAccount, useSignTypedData } from "wagmi";
import { TOKENS } from "../config";
import { useQuote } from "../hooks/useQuote";
import { useIntent } from "../hooks/useIntent";
import { IntentStatus } from "./IntentStatus";

export function SwapForm() {
const { address, isConnected } = useAccount();
const { signTypedDataAsync } = useSignTypedData();

const [amount, setAmount] = useState("");
const [recipient, setRecipient] = useState("");

const fromToken = TOKENS.tHSK.address;
const toToken = TOKENS.tUSDC.address;

const { quote, loading: quoteLoading } = useQuote(fromToken, toToken, amount);
const { state, submit, reset } = useIntent();

const isActive = state.status !== "idle" && state.status !== "completed" && state.status !== "failed";

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!address) return;
await submit({
fromToken,
toToken,
amount,
recipient,
userAddress: address,
signTypedData: (args) => signTypedDataAsync(args as any),
});
}

if (state.status === "completed" || state.status === "failed") {
return <IntentStatus state={state} onReset={reset} />;
}

return (
<form className="swap-form" onSubmit={handleSubmit}>
<h2>Send Payment</h2>

<div className="token-row">
<span className="token-label">You send</span>
<span className="token-badge">{TOKENS.tHSK.symbol}</span>
</div>

<div className="field">
<label>Amount</label>
<input
type="number"
placeholder="0.0"
value={amount}
onChange={e => setAmount(e.target.value)}
min="0"
step="any"
required
/>
</div>

<div className="token-row">
<span className="token-label">Recipient receives</span>
<span className="token-badge">{TOKENS.tUSDC.symbol}</span>
</div>

{amount && !quoteLoading && quote?.bestRoute && (
<div className="quote-preview">
≈ {quote.bestRoute.steps[0]?.estimatedOutput} tUSDC
</div>
)}

<div className="field">
<label>Recipient address</label>
<input
type="text"
placeholder="0x..."
value={recipient}
onChange={e => setRecipient(e.target.value)}
required
/>
</div>

{isActive && (
<div className="status-inline">
{state.status === "signing" && "⏳ Waiting for signature..."}
{state.status === "submitting" && "⏳ Submitting intent..."}
{state.status === "polling" && "⏳ Executing swap..."}
</div>
)}

{state.error && <div className="error">{state.error}</div>}

<button type="submit" disabled={!isConnected || isActive}>
{!isConnected ? "Connect wallet first" : isActive ? "Processing..." : "Send"}
</button>
</form>
);
}
23 changes: 23 additions & 0 deletions packages/app/src/components/WalletButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useAccount, useConnect, useDisconnect } from "wagmi";
import { injected } from "wagmi/connectors";

export function WalletButton() {
const { address, isConnected } = useAccount();
const { connect } = useConnect();
const { disconnect } = useDisconnect();

if (isConnected) {
return (
<div className="wallet-connected">
<span>{address?.slice(0, 6)}...{address?.slice(-4)}</span>
<button onClick={() => disconnect()}>Disconnect</button>
</div>
);
}

return (
<button className="wallet-connect-btn" onClick={() => connect({ connector: injected() })}>
Connect Wallet
</button>
);
}
13 changes: 13 additions & 0 deletions packages/app/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const GRIFFIN_API_URL = import.meta.env.VITE_GRIFFIN_API_URL || "http://localhost:3000";

export const HASHKEY_TESTNET = {
id: 133,
name: "HashKey Testnet",
nativeCurrency: { name: "HSK", symbol: "HSK", decimals: 18 },
rpcUrls: { default: { http: ["https://testnet.hsk.xyz"] } },
} as const;

export const TOKENS = {
tHSK: { address: "0xb8F355f10569FD2A765296161d082Cc37c5843c2", symbol: "tHSK", decimals: 18 },
tUSDC: { address: "0xc4C2841367016C9e2652Fecc49bBA9229787bA82", symbol: "tUSDC", decimals: 6 },
};
10 changes: 10 additions & 0 deletions packages/app/src/hooks/useGriffinClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useMemo } from "react";
import { GriffinClient } from "@griffin/sdk";
import { GRIFFIN_API_URL } from "../config";

export function useGriffinClient() {
return useMemo(
() => new GriffinClient({ baseUrl: GRIFFIN_API_URL, timeoutMs: 60_000 }),
[],
);
}
106 changes: 106 additions & 0 deletions packages/app/src/hooks/useIntent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useState } from "react";
import { useGriffinClient } from "./useGriffinClient";
import type { IntentStatus } from "@griffin/sdk";

export type IntentState = {
status: "idle" | "signing" | "submitting" | "polling" | IntentStatus;
intentId?: string;
error?: string;
};

// Must match the EIP-712 types defined in the orchestrator's validateEvmSignature
const INTENT_DOMAIN = {
name: "Griffin",
version: "1",
chainId: 133, // Hashkey testnet
} as const;

const INTENT_TYPES = {
IntentAuthorization: [
{ name: "fromToken", type: "address" },
{ name: "toToken", type: "address" },
{ name: "amount", type: "string" },
{ name: "recipient", type: "address" },
{ name: "userAddress", type: "address" },
{ name: "nonce", type: "uint256" },
],
} as const;

export function useIntent() {
const client = useGriffinClient();
const [state, setState] = useState<IntentState>({ status: "idle" });

async function submit(params: {
fromToken: string;
toToken: string;
amount: string;
recipient: string;
userAddress: string;
signTypedData: (args: {
domain: typeof INTENT_DOMAIN;
types: typeof INTENT_TYPES;
primaryType: "IntentAuthorization";
message: Record<string, unknown>;
}) => Promise<string>;
}) {
try {
setState({ status: "signing" });

const nonce = Date.now();

const typedMessage = {
fromToken: params.fromToken,
toToken: params.toToken,
amount: params.amount,
recipient: params.recipient,
userAddress: params.userAddress,
nonce,
};

const signature = await params.signTypedData({
domain: INTENT_DOMAIN,
types: INTENT_TYPES,
primaryType: "IntentAuthorization",
message: typedMessage,
});

// requestMessage carries the typed data so the orchestrator can reconstruct it
const requestMessage = JSON.stringify({
fromChain: "eip155:133",
toChain: "eip155:133",
...typedMessage,
});

setState({ status: "submitting" });

const intent = await client.createIntent({
fromChain: "eip155:133",
toChain: "eip155:133",
fromToken: params.fromToken,
toToken: params.toToken,
amount: params.amount,
recipient: params.recipient,
userAddress: params.userAddress,
requestMessage,
requestSignature: signature,
});

setState({ status: "polling", intentId: intent.intentId });

await client.executeIntent(intent.intentId);

for (let i = 0; i < 20; i++) {
await new Promise(r => setTimeout(r, 2000));
const updated = await client.getIntent(intent.intentId);
setState({ status: updated.status as IntentStatus, intentId: intent.intentId });
if (updated.status === "completed" || updated.status === "failed") break;
}
} catch (e: unknown) {
setState({ status: "failed", error: e instanceof Error ? e.message : String(e) });
}
}

function reset() { setState({ status: "idle" }); }

return { state, submit, reset };
}
22 changes: 22 additions & 0 deletions packages/app/src/hooks/useQuote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useState, useEffect } from "react";
import { useGriffinClient } from "./useGriffinClient";
import type { QuoteResponse } from "@griffin/sdk";

export function useQuote(fromToken: string, toToken: string, amount: string) {
const client = useGriffinClient();
const [quote, setQuote] = useState<QuoteResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!amount || parseFloat(amount) <= 0) { setQuote(null); return; }
setLoading(true);
setError(null);
client.getQuote({ fromChain: "eip155:133", toChain: "eip155:133", fromToken, toToken, amount })
.then(setQuote)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [fromToken, toToken, amount]);

return { quote, loading, error };
}
Loading
Loading