diff --git a/packages/intent-aggregator/src/index.ts b/packages/intent-aggregator/src/index.ts index 45a52c6..cd73fa8 100644 --- a/packages/intent-aggregator/src/index.ts +++ b/packages/intent-aggregator/src/index.ts @@ -120,6 +120,9 @@ const INTENT_NOT_FOUND_ERROR = { api.openapi(getIntent, async (c) => { const intent = await c.var.db.query.intents.findFirst({ where: (intents, { eq }) => eq(intents.id, c.req.param("id")), + with: { + winningSolution: true, + }, }); if (!intent) { diff --git a/packages/upi-maker/package.json b/packages/upi-maker/package.json index 2bb908d..a4ebbfd 100644 --- a/packages/upi-maker/package.json +++ b/packages/upi-maker/package.json @@ -35,6 +35,7 @@ "next-themes": "^0.4.4", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-qr-code": "^2.0.15", "sonner": "^1.7.0", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", diff --git a/packages/upi-maker/src/components/intent-details.tsx b/packages/upi-maker/src/components/intent-details.tsx index 6c58100..0c28e88 100644 --- a/packages/upi-maker/src/components/intent-details.tsx +++ b/packages/upi-maker/src/components/intent-details.tsx @@ -10,9 +10,10 @@ import { useMutation, useQuery } from '@tanstack/react-query' import { Skeleton } from './ui/skeleton' import { useActiveAccount } from 'thirdweb/react' import { keccak256, toBytes, toUnits, toWei, toTokens } from 'thirdweb' -import { baseSepolia } from 'thirdweb/chains' +import { baseSepolia } from 'thirdweb/chains'; +import QRCode from "react-qr-code"; import { intentAggregatorApi } from '@/queries/conts' -import { solutionsQuery, solutionsQueryOptions } from '@/queries/solutions' +import { solutionsQueryOptions } from '@/queries/solutions' // EIP-712 Type Definitions const DOMAIN = { @@ -53,8 +54,8 @@ type IntentMutationArgs = { export function IntentDetails({ intentId }: { intentId: string }) { const [quoteAmount, setQuoteAmount] = useState('') - const intentQuery = useQuery(intentQueryOptions(intentId)); - const solutionsQuery = useQuery(solutionsQueryOptions(intentId)); + const intentQuery = useQuery({ ...intentQueryOptions(intentId), refetchInterval: 5000 }); + const solutionsQuery = useQuery({ ...solutionsQueryOptions(intentId), refetchInterval: 5000 }); const account = useActiveAccount(); @@ -73,6 +74,7 @@ export function IntentDetails({ intentId }: { intentId: string }) { }) intentQuery.refetch(); + solutionsQuery.refetch(); } }) @@ -130,9 +132,26 @@ export function IntentDetails({ intentId }: { intentId: string }) { ) } - const handlePaymentConfirmation = () => { - toast.success("Payment confirmation received. Awaiting settlement.") - } + const paymentClaimMutation = useMutation({ + mutationFn: async () => { + if (!mySolution?.id) throw new Error("Solution not found"); + + await intentAggregatorApi.post(`solutions/${mySolution.id}/claim`, { + json: { + paymentMetadata: { + "transactionId": "UPI/123/456", + "timestamp": new Date().toISOString(), + "railSpecificData": {} + } + } + }) + + toast.success("Payment claimed successfully"); + + intentQuery.refetch(); + solutionsQuery.refetch(); + } + }) if (loading) { return ( @@ -182,8 +201,10 @@ export function IntentDetails({ intentId }: { intentId: string }) { {intent.state === "SOLUTION_COMMITTED" && (
-
- QR Code Stub +
+
)} @@ -214,8 +235,12 @@ export function IntentDetails({ intentId }: { intentId: string }) {
- Polygon Logo - Payment will be received on Polygon + {/* https://cryptologos.cc/logos/polygon-matic-logo.svg?v=025 */} + Base Sepolia Logo + Payment { + intent.state === "SETTLED" || intent.state === "RESOLVED" ? "has been " : "will be " + } + received on Base Sepolia
{intent.state === "CREATED" && ( )} + { + intent.state === "SETTLED" && ( +
+
+

Taker has settled the payment. Your bond has been refuned.

+

+ Transaction Hash: {intent.winningSolution?.settlementTxHash} +

+
+
+ ) + }
diff --git a/packages/upi-maker/src/components/intents-list.tsx b/packages/upi-maker/src/components/intents-list.tsx index ab43429..a0c0d7c 100644 --- a/packages/upi-maker/src/components/intents-list.tsx +++ b/packages/upi-maker/src/components/intents-list.tsx @@ -39,7 +39,7 @@ export function IntentsList({ {/* {intent.state === '' && ( )} */} - {intent.state === 'RESOLVED' && ( + {intent.state === 'SETTLED' || intent.state === 'RESOLVED' && ( )} diff --git a/packages/upi-taker/package.json b/packages/upi-taker/package.json index 16b8c67..f29b4ab 100644 --- a/packages/upi-taker/package.json +++ b/packages/upi-taker/package.json @@ -22,6 +22,7 @@ "vite": "^6.0.3" }, "dependencies": { + "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", "@tanstack/react-query": "^5.62.3", diff --git a/packages/upi-taker/src/components/transaction-timeline.tsx b/packages/upi-taker/src/components/transaction-timeline.tsx new file mode 100644 index 0000000..5e4bcaa --- /dev/null +++ b/packages/upi-taker/src/components/transaction-timeline.tsx @@ -0,0 +1,37 @@ +import { Check, Loader2 } from 'lucide-react' + +interface Step { + label: string + status: 'pending' | 'active' | 'completed' +} + +interface TransactionTimelineProps { + steps: Step[] +} + +export function TransactionTimeline({ steps }: TransactionTimelineProps) { + return ( +
+ {steps.map((step, index) => ( +
+
+ {step.status === 'completed' ? ( + + ) : step.status === 'active' ? ( + + ) : null} +
+ + {step.label} + +
+ ))} +
+ ) +} + diff --git a/packages/upi-taker/src/components/ui/dialog.tsx b/packages/upi-taker/src/components/ui/dialog.tsx new file mode 100644 index 0000000..9dbeaa0 --- /dev/null +++ b/packages/upi-taker/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/packages/upi-taker/src/routeTree.gen.ts b/packages/upi-taker/src/routeTree.gen.ts index dd9bda7..db0a1cf 100644 --- a/packages/upi-taker/src/routeTree.gen.ts +++ b/packages/upi-taker/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as HomeNavImport } from './routes/home/_nav' import { Route as HomeNavScanImport } from './routes/home/_nav.scan' import { Route as HomeNavOrdersImport } from './routes/home/_nav.orders' import { Route as PayVpaNameIndexImport } from './routes/pay.$vpa.$name/index' +import { Route as PayVpaNameIntentWaitImport } from './routes/pay.$vpa.$name/$intent.wait' import { Route as PayVpaNameIntentQuoteImport } from './routes/pay.$vpa.$name/$intent.quote' // Create Virtual Routes @@ -68,6 +69,12 @@ const PayVpaNameIndexRoute = PayVpaNameIndexImport.update({ getParentRoute: () => rootRoute, } as any) +const PayVpaNameIntentWaitRoute = PayVpaNameIntentWaitImport.update({ + id: '/pay/$vpa/$name/$intent/wait', + path: '/pay/$vpa/$name/$intent/wait', + getParentRoute: () => rootRoute, +} as any) + const PayVpaNameIntentQuoteRoute = PayVpaNameIntentQuoteImport.update({ id: '/pay/$vpa/$name/$intent/quote', path: '/pay/$vpa/$name/$intent/quote', @@ -134,6 +141,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PayVpaNameIntentQuoteImport parentRoute: typeof rootRoute } + '/pay/$vpa/$name/$intent/wait': { + id: '/pay/$vpa/$name/$intent/wait' + path: '/pay/$vpa/$name/$intent/wait' + fullPath: '/pay/$vpa/$name/$intent/wait' + preLoaderRoute: typeof PayVpaNameIntentWaitImport + parentRoute: typeof rootRoute + } } } @@ -172,6 +186,7 @@ export interface FileRoutesByFullPath { '/home/scan': typeof HomeNavScanRoute '/pay/$vpa/$name': typeof PayVpaNameIndexRoute '/pay/$vpa/$name/$intent/quote': typeof PayVpaNameIntentQuoteRoute + '/pay/$vpa/$name/$intent/wait': typeof PayVpaNameIntentWaitRoute } export interface FileRoutesByTo { @@ -181,6 +196,7 @@ export interface FileRoutesByTo { '/home/scan': typeof HomeNavScanRoute '/pay/$vpa/$name': typeof PayVpaNameIndexRoute '/pay/$vpa/$name/$intent/quote': typeof PayVpaNameIntentQuoteRoute + '/pay/$vpa/$name/$intent/wait': typeof PayVpaNameIntentWaitRoute } export interface FileRoutesById { @@ -193,6 +209,7 @@ export interface FileRoutesById { '/home/_nav/scan': typeof HomeNavScanRoute '/pay/$vpa/$name/': typeof PayVpaNameIndexRoute '/pay/$vpa/$name/$intent/quote': typeof PayVpaNameIntentQuoteRoute + '/pay/$vpa/$name/$intent/wait': typeof PayVpaNameIntentWaitRoute } export interface FileRouteTypes { @@ -205,6 +222,7 @@ export interface FileRouteTypes { | '/home/scan' | '/pay/$vpa/$name' | '/pay/$vpa/$name/$intent/quote' + | '/pay/$vpa/$name/$intent/wait' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -213,6 +231,7 @@ export interface FileRouteTypes { | '/home/scan' | '/pay/$vpa/$name' | '/pay/$vpa/$name/$intent/quote' + | '/pay/$vpa/$name/$intent/wait' id: | '__root__' | '/' @@ -223,6 +242,7 @@ export interface FileRouteTypes { | '/home/_nav/scan' | '/pay/$vpa/$name/' | '/pay/$vpa/$name/$intent/quote' + | '/pay/$vpa/$name/$intent/wait' fileRoutesById: FileRoutesById } @@ -231,6 +251,7 @@ export interface RootRouteChildren { HomeRoute: typeof HomeRouteWithChildren PayVpaNameIndexRoute: typeof PayVpaNameIndexRoute PayVpaNameIntentQuoteRoute: typeof PayVpaNameIntentQuoteRoute + PayVpaNameIntentWaitRoute: typeof PayVpaNameIntentWaitRoute } const rootRouteChildren: RootRouteChildren = { @@ -238,6 +259,7 @@ const rootRouteChildren: RootRouteChildren = { HomeRoute: HomeRouteWithChildren, PayVpaNameIndexRoute: PayVpaNameIndexRoute, PayVpaNameIntentQuoteRoute: PayVpaNameIntentQuoteRoute, + PayVpaNameIntentWaitRoute: PayVpaNameIntentWaitRoute, } export const routeTree = rootRoute @@ -253,7 +275,8 @@ export const routeTree = rootRoute "/", "/home", "/pay/$vpa/$name/", - "/pay/$vpa/$name/$intent/quote" + "/pay/$vpa/$name/$intent/quote", + "/pay/$vpa/$name/$intent/wait" ] }, "/": { @@ -291,6 +314,9 @@ export const routeTree = rootRoute }, "/pay/$vpa/$name/$intent/quote": { "filePath": "pay.$vpa.$name/$intent.quote.tsx" + }, + "/pay/$vpa/$name/$intent/wait": { + "filePath": "pay.$vpa.$name/$intent.wait.tsx" } } } diff --git a/packages/upi-taker/src/routes/__root.tsx b/packages/upi-taker/src/routes/__root.tsx index 3677819..420bac9 100644 --- a/packages/upi-taker/src/routes/__root.tsx +++ b/packages/upi-taker/src/routes/__root.tsx @@ -12,7 +12,6 @@ function RootComponent() { <> - ) diff --git a/packages/upi-taker/src/routes/pay.$vpa.$name/$intent.quote.tsx b/packages/upi-taker/src/routes/pay.$vpa.$name/$intent.quote.tsx index eb8e819..0a70641 100644 --- a/packages/upi-taker/src/routes/pay.$vpa.$name/$intent.quote.tsx +++ b/packages/upi-taker/src/routes/pay.$vpa.$name/$intent.quote.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, Link } from '@tanstack/react-router' +import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import { useState, useEffect } from 'react' import { Button, buttonVariants } from '@/components/ui/button' import { Loader2, HelpCircle, ArrowLeftRight } from 'lucide-react' @@ -12,13 +12,13 @@ import { Card, CardContent } from '@/components/ui/card' import { useMutation, useQuery } from '@tanstack/react-query' import { solutionsQueryOptions } from '@/queries/solutions' import { intentQueryOptions } from '@/queries/intent' -import { getContract, Hex, keccak256, prepareContractCall, readContract, sendTransaction, stringToHex, toBytes, toTokens, toUnits } from 'thirdweb' +import { getContract, Hex, keccak256, prepareContractCall, readContract, sendAndConfirmTransaction, sendTransaction, stringToHex, toBytes, toTokens, toUnits } from 'thirdweb' import { shortenAddress } from 'thirdweb/utils' import { ConnectButton, useActiveAccount } from 'thirdweb/react' import { client } from '@/client' import { baseSepolia } from 'thirdweb/chains' import { zkRailUsdc } from '.' -import { error } from 'console' +import { intentAggregatorApi } from '@/queries/conts' export const zkRailUPI = "0x926B9bD1905CfeC995B0955DE7392bEdECE2FDC9"; @@ -28,7 +28,7 @@ export const Route = createFileRoute('/pay/$vpa/$name/$intent/quote')({ // Helper for converting intent ID to bytes32 -function intentIdToBytes32(intentId: string): `0x${string}` { +export function intentIdToBytes32(intentId: string): `0x${string}` { // If already hex if (intentId.startsWith('0x')) { return intentId as `0x${string}` @@ -36,6 +36,7 @@ function intentIdToBytes32(intentId: string): `0x${string}` { // If base58/UUID style, hash it return keccak256(toBytes(intentId)) as `0x${string}` } + function RouteComponent() { const { vpa, name, intent: intentId } = Route.useParams(); const account = useActiveAccount(); @@ -43,6 +44,7 @@ function RouteComponent() { const intentSolutionsQuery = useQuery({ ...solutionsQueryOptions(intentId), refetchInterval: 2000 }); const intentQuery = useQuery(intentQueryOptions(intentId)); + const navigate = useNavigate(); const lowestQuote = intentSolutionsQuery.data?.[0]; const lowestQuoteAmount = intentSolutionsQuery.data?.[0]?.amountWei ? BigInt(intentSolutionsQuery.data?.[0].amountWei) : undefined; @@ -84,32 +86,37 @@ function RouteComponent() { account.address ] as const; - console.log('Formatted solution:', formattedSolution); - console.log('Signature:', lowestQuote.signature); - - // The contract function now expects a single struct - console.log(keccak256(stringToHex( - 'IntentSolution(bytes32 intentId,string railType,string recipientAddress,uint256 railAmount,address paymentToken,uint256 paymentAmount,address bondToken,uint256 bondAmount,address intentCreator)' - ))) - const transaction = prepareContractCall({ contract: zkUpiContract, method: "function commitToSolution((bytes32,string,string,uint256,address,uint256,address,uint256,address),bytes)" as const, params: [formattedSolution, lowestQuote.signature as Hex], }); - const { transactionHash } = await sendTransaction({ + const { transactionHash } = await sendAndConfirmTransaction({ account, transaction, }); + console.log(transactionHash); + await intentAggregatorApi.post(`solutions/${lowestQuote.id}/accept`, { + json: { + commitmentTxHash: transactionHash + } + }); + + navigate({ + to: "/pay/$vpa/$name/$intent/wait", + params: { + intent: intentId, + name, vpa + } + }) + }, onError: (error) => { console.error(error) } - } - - ); + }); if (loading) { @@ -152,12 +159,15 @@ function RouteComponent() {
+
Quote amount {toTokens(BigInt(lowestQuote.amountWei), 6)} USDC
+ +
@@ -184,6 +194,8 @@ function RouteComponent() {
+ +
@@ -208,6 +220,22 @@ function RouteComponent() { 500 USDC
+ +
+ Receiving At + + {vpa} + +
+ +
+ Receiving Amount + + {intentQuery.data?.railAmount ? toTokens(BigInt(intentQuery.data?.railAmount), 2) : "..."} INR + +
+ +
@@ -237,6 +265,7 @@ function RouteComponent() { className="w-full btn-glossy" onClick={() => commitSolutionMutation.mutate()} > + {commitSolutionMutation.isPending && } Confirm Payment { + if (!winningSolution) throw new Error("INVALID STATE"); + if (!account) throw new Error("Please connect wallet"); + + const zkUpiContract = getContract({ + address: zkRailUPI, + chain: baseSepolia, + client + }); + + + const transaction = prepareContractCall({ + contract: zkUpiContract, + method: "function settle(bytes32)" as const, + params: [intentIdToBytes32(intentId)] + }); + + const { transactionHash } = await sendAndConfirmTransaction({ + account, + transaction, + }); + + console.log(transactionHash); + + await intentAggregatorApi.post(`solutions/${winningSolution.id}/settle`, { + json: { + settlementTxHash: transactionHash + } + }); + // return await client.post(`/intents/${intentId}/settle`).json() + }, + onError: (err) => { + console.error(err); + } + }) + + + + return ( +
+
+

Payment Status

+ + {isUpiConfirmed && + <> + { + intentQuery.data?.state !== "SETTLED" ? + <> +

Confirm you have received the payment to settle the transaction and receive your 50% collateral.

+ + + + + + + + Warning + + If you have not received the payment, you will receive your original amount + the bond amount (500 USDC) after 48 hours. + + +
+ +

+ If you maliciously do not confirm the payment despite the taker having made the payment, the maker can submit a zkproof of this transaction. If the zkproof is verified on-chain, you will lose the bond amount. It's better to settle the payment once received. +

+
+
+
+ + : + Done + + } + } +
+
+ ) +} + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bda94df..121897d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-qr-code: + specifier: ^2.0.15 + version: 2.0.15(react@18.3.1) sonner: specifier: ^1.7.0 version: 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -183,6 +186,9 @@ importers: packages/upi-taker: dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': specifier: ^1.1.0 version: 1.1.0(@types/react@18.3.14)(react@18.3.1) @@ -3364,9 +3370,15 @@ packages: process-warning@1.0.0: resolution: {integrity: sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-compare@2.5.1: resolution: {integrity: sha512-oyfc0Tx87Cpwva5ZXezSp5V9vht1c7dZBhvuV/y3ctkgMVUmiAGDVeeB0dKhGSyT0v1ZTEQYpe/RXlBVBNuCLA==} + qr.js@0.0.0: + resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==} + qrcode@1.5.3: resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==} engines: {node: '>=10.13.0'} @@ -3403,6 +3415,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-qr-code@2.0.15: + resolution: {integrity: sha512-MkZcjEXqVKqXEIMVE0mbcGgDpkfSdd8zhuzXEl9QzYeNcw8Hq2oVIzDLWuZN2PQBwM5PWjc2S31K8Q1UbcFMfw==} + peerDependencies: + react: '*' + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -7272,8 +7289,16 @@ snapshots: process-warning@1.0.0: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + proxy-compare@2.5.1: {} + qr.js@0.0.0: {} + qrcode@1.5.3: dependencies: dijkstrajs: 1.0.3 @@ -7313,6 +7338,12 @@ snapshots: react: 18.3.1 use-deep-compare-effect: 1.8.1(react@18.3.1) + react-qr-code@2.0.15(react@18.3.1): + dependencies: + prop-types: 15.8.1 + qr.js: 0.0.0 + react: 18.3.1 + react-refresh@0.14.2: {} react-remove-scroll-bar@2.3.6(@types/react@18.3.14)(react@18.3.1):