diff --git a/sites/dapp-ui/src/App.tsx b/sites/dapp-ui/src/App.tsx index 4c9eae2..865d4d8 100644 --- a/sites/dapp-ui/src/App.tsx +++ b/sites/dapp-ui/src/App.tsx @@ -122,7 +122,7 @@ export default function ProviderApp() { client.off('connect', handleSocketConnect); client.off('disconnect', handleSocketDisconnect); }; - }, [client]); + }, [client, setAddress]); return ( - + @@ -78,9 +74,14 @@ export default function Layout({ children }: PropsWithChildren) { - {children} + {children}
diff --git a/sites/dapp-ui/src/components/ConnectModal.tsx b/sites/dapp-ui/src/components/ConnectModal.tsx index 95fe29b..5e31376 100644 --- a/sites/dapp-ui/src/components/ConnectModal.tsx +++ b/sites/dapp-ui/src/components/ConnectModal.tsx @@ -28,13 +28,13 @@ export function ConnectModal({ color, }: { color?: - | 'inherit' - | 'primary' - | 'secondary' - | 'success' - | 'error' - | 'info' - | 'warning'; + | 'inherit' + | 'primary' + | 'secondary' + | 'success' + | 'error' + | 'info' + | 'warning'; }) { const { client, dataChannel } = useSignalClient(); const navigate = useNavigate(); @@ -60,10 +60,10 @@ export function ConnectModal({ }, { urls: [ - "turn:global.relay.metered.ca:80", - "turn:global.relay.metered.ca:80?transport=tcp", - "turn:global.relay.metered.ca:443", - "turns:global.relay.metered.ca:443?transport=tcp" + 'turn:global.relay.metered.ca:80', + 'turn:global.relay.metered.ca:80?transport=tcp', + 'turn:global.relay.metered.ca:443', + 'turns:global.relay.metered.ca:443?transport=tcp', ], // default username and credential when the turn server doesn't // use auth, the client still requires a value @@ -102,32 +102,32 @@ export function ConnectModal({ diff --git a/sites/dapp-ui/src/components/EmptyAccountCard.tsx b/sites/dapp-ui/src/components/EmptyAccountCard.tsx new file mode 100644 index 0000000..a4dbf5f --- /dev/null +++ b/sites/dapp-ui/src/components/EmptyAccountCard.tsx @@ -0,0 +1,22 @@ +import Card from '@mui/material/Card'; +import { CardHeader, Link } from '@mui/material'; +import CardContent from '@mui/material/CardContent'; +import Typography from '@mui/material/Typography'; + +export function EmptyAccountCard({ address }: { address: string }) { + return ( + + + Visit the faucet to fund your account. + + } + > + + {address} + + + ); +} diff --git a/sites/dapp-ui/src/components/MessageCard.tsx b/sites/dapp-ui/src/components/MessageCard.tsx new file mode 100644 index 0000000..34d5e70 --- /dev/null +++ b/sites/dapp-ui/src/components/MessageCard.tsx @@ -0,0 +1,46 @@ +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Typography from '@mui/material/Typography'; +import { Message } from '@/store.ts'; +import { CardHeader } from '@mui/material'; + +export function MessageCard({ msg }: { msg: Message }) { + let message; + const isLocal = msg.type === 'local'; + switch (msg.data.type) { + case 'credential': + message = isLocal + ? `🔑 Credential Sent: ${msg.data.id}` + : `🔑 Credential Received: ${msg.data.id}`; + break; + case 'transaction': + message = isLocal + ? `🚚 Transaction Sent: ${msg.data.txId}` + : `🚚 Transaction Received: ${msg.data.txId}`; + break; + case 'transaction-signature': + message = isLocal + ? `🔏 Signature Sent: ${msg.data.txId}` + : `🔏 Signature Received: ${msg.data.txId}`; + break; + default: + message = 'Unknown message'; + } + return ( + + + + Message + + + + + {message} + + + + ); +} diff --git a/sites/dapp-ui/src/components/TransactionCard.tsx b/sites/dapp-ui/src/components/TransactionCard.tsx new file mode 100644 index 0000000..a1b01f5 --- /dev/null +++ b/sites/dapp-ui/src/components/TransactionCard.tsx @@ -0,0 +1,40 @@ +import Card from '@mui/material/Card'; +import { LiquidTransaction } from '@/store.ts'; +import { CardHeader, Link } from '@mui/material'; + +export function TransactionCard({ txn }: { txn: LiquidTransaction }) { + const isLive = txn.status === 'confirmed'; + let emoji; + switch (txn.status) { + case 'confirmed': + emoji = '✅'; + break; + case 'submitted': + emoji = '🔵'; + break; + case 'failed': + emoji = '❌'; + break; + default: + emoji = '🟡'; + } + return ( + + + {txn.txn.txID()} + + ) : ( + txn.txn.txID() + ) + } + > + + ); +} diff --git a/sites/dapp-ui/src/components/user/SessionMenu.tsx b/sites/dapp-ui/src/components/user/SessionMenu.tsx index a6d97ec..5e38896 100644 --- a/sites/dapp-ui/src/components/user/SessionMenu.tsx +++ b/sites/dapp-ui/src/components/user/SessionMenu.tsx @@ -30,7 +30,7 @@ export function SessionMenu() { if (isConnected && !hasDataChannel) { setBadgeColor('warning'); } - }, [dataChannel, isConnected]); + }, [dataChannel, isConnected, hasDataChannel]); return ( <> diff --git a/sites/dapp-ui/src/components/user/StatusCard.tsx b/sites/dapp-ui/src/components/user/StatusCard.tsx index 5ef0b93..073464c 100644 --- a/sites/dapp-ui/src/components/user/StatusCard.tsx +++ b/sites/dapp-ui/src/components/user/StatusCard.tsx @@ -6,6 +6,11 @@ import Typography from '@mui/material/Typography'; import ShareIcon from '@mui/icons-material/Share'; import LogoutIcon from '@mui/icons-material/Logout'; import { User } from './types.ts'; +import ListItem from '@mui/material/ListItem'; +import List from '@mui/material/List'; +import { SignalClientContext } from '@/hooks/useSignalClient.ts'; +import { useContext } from 'react'; +import { useUserState } from '@/hooks'; export type ProfileCardProps = { socket: { isConnected: boolean; @@ -26,29 +31,49 @@ export type ProfileCardProps = { user: User | null; }; export function StatusCard({ session, user, socket }: ProfileCardProps) { - const { cookie, ...sessionData } = session; + const { refetch } = useUserState(); + const { client, setDataChannel } = useContext(SignalClientContext); return ( - - Session - -
{JSON.stringify(sessionData, null, 2)}
- - Signaling Service - -
{JSON.stringify(socket, null, 2)}
- - Cookie - -
{JSON.stringify(cookie, null, 2)}
+ + + + Address:{' '} + {session?.wallet + ? `${session.wallet.substring( + 0, + 4, + )}...${session.wallet.substring( + session.wallet.length - 4, + session.wallet.length, + )}` + : 'Anonymous'} + + + + + + + Connected: {socket.isConnected ? 'Yes' : 'No'} + + + + + DataChannel: {socket.hasDataChannel ? 'Yes' : 'No'} + + +
{user && ( { - fetch('/auth/logout'); + onClick={async () => { + await fetch('/auth/logout'); + await refetch(); + client && client.close(); + setDataChannel(null); }} > diff --git a/sites/dapp-ui/src/hooks/useSignalClient.ts b/sites/dapp-ui/src/hooks/useSignalClient.ts index 42de87f..562b51f 100644 --- a/sites/dapp-ui/src/hooks/useSignalClient.ts +++ b/sites/dapp-ui/src/hooks/useSignalClient.ts @@ -7,7 +7,7 @@ type SignalClientState = { status: 'connected' | 'disconnected'; setStatus: (_: 'connected' | 'disconnected') => void; dataChannel: RTCDataChannel | null; - setDataChannel: (_: RTCDataChannel) => void; + setDataChannel: (_: RTCDataChannel | null) => void; }; export const SignalClientContext = createContext({ client: null, diff --git a/sites/dapp-ui/src/index.css b/sites/dapp-ui/src/index.css index a9af054..9ff4710 100644 --- a/sites/dapp-ui/src/index.css +++ b/sites/dapp-ui/src/index.css @@ -1,15 +1,9 @@ -#root, body, html { - height: 100vh; - width: 100vw; - overflow: hidden !important; -} - .ocean { position: absolute; top: 0; left: 0; height: 100vh; - width: 100vw; + width: 98vw; z-index: -10; overflow: hidden !important; } diff --git a/sites/dapp-ui/src/pages/connected.tsx b/sites/dapp-ui/src/pages/connected.tsx index a71c853..c9d41f2 100644 --- a/sites/dapp-ui/src/pages/connected.tsx +++ b/sites/dapp-ui/src/pages/connected.tsx @@ -2,7 +2,6 @@ import { useDataChannel } from '@/hooks/useDataChannel.ts'; import { useEffect, useState } from 'react'; import Button from '@mui/material/Button'; import { - Transaction, encodeUnsignedTransaction, waitForConfirmation, makePaymentTxnWithSuggestedParamsFromObject, @@ -11,99 +10,77 @@ import { toBase64URL, fromBase64Url } from '@algorandfoundation/liquid-client'; import { useAlgod } from '@/hooks/useAlgod.ts'; import { useAccountInfo } from '@/hooks/useAccountInfo.ts'; import FormControl from '@mui/material/FormControl'; -import { Box, CircularProgress, Input, Slider } from '@mui/material'; +import { Box, Input, Paper, Slider } from '@mui/material'; import Typography from '@mui/material/Typography'; -import { useAddressStore, useMessageStore } from '../store.ts'; +import { + useAddressStore, + useMessageStore, + useTransactionStore, +} from '../store.ts'; import { useNavigate } from 'react-router-dom'; +import { MessageCard } from '@/components/MessageCard.tsx'; +import { TransactionCard } from '@/components/TransactionCard.tsx'; +import { EmptyAccountCard } from '@/components/EmptyAccountCard.tsx'; export function ConnectedPage() { const navigate = useNavigate(); const algod = useAlgod(); const wallet = useAddressStore((state) => state.address); - const [txn, setTxn] = useState(null); - // const datachannel = useDataChannel('remote', peerConnection); + const accountInfo = useAccountInfo(wallet, 3000); const [from, setFrom] = useState(wallet); const [to, setTo] = useState(wallet); const [amount, setAmount] = useState(0); - const [isWaitingForSignature, setIsWaitingForSignature] = useState(false); - const [isWaitingForConfirmation, setIsWaitingForConfirmation] = - useState(false); const addMessage = useMessageStore((state) => state.addMessage); const messages = useMessageStore((state) => state.messages); + const addTransaction = useTransactionStore((state) => state.addTransaction); + const updateTransaction = useTransactionStore( + (state) => state.updateTransaction, + ); + const transactions = useTransactionStore((state) => state.transactions); // Receive response const datachannel = useDataChannel((event) => { - console.log(event.data); + const message = JSON.parse(event.data); + // TODO: Handle P2P using a previously known credential + //if (message.type === 'credential') localStorage['credId'] = message.id; + if (message.type !== 'transaction-signature') return; addMessage({ type: 'remote', data: JSON.parse(event.data), timestamp: Date.now(), }); - if (!txn) return; async function handleMessage() { - if (!txn) return; const message = JSON.parse(event.data); if (message.type === 'credential') localStorage['credId'] = message.id; if (message.type !== 'transaction-signature') return; - - if (txn.txID() !== message.txId) throw new Error('Invalid txId'); - + const txn = transactions.find((txn) => txn.txId === message.txId); + if (!txn) return; const sig = fromBase64Url(message.sig); - const signedTxn = txn.attachSignature((!!accountInfo.data?.['auth-addr']) ? accountInfo.data?.['auth-addr'] : wallet, sig); - - setIsWaitingForSignature(false); - setIsWaitingForConfirmation(true); + const signedTxn = txn.txn.attachSignature( + accountInfo.data?.['auth-addr'] + ? accountInfo.data?.['auth-addr'] + : wallet, + sig, + ); + updateTransaction(txn.txId, 'signed'); const { txId } = await algod.sendRawTransaction(signedTxn).do(); + updateTransaction(txn.txId, 'submitted'); await waitForConfirmation(algod, txId, 4); - setIsWaitingForConfirmation(false); + updateTransaction(txn.txId, 'confirmed'); } handleMessage(); }); - // Send Transaction - useEffect(() => { - if ( - !txn || - !datachannel || - isWaitingForSignature || - isWaitingForConfirmation - ) - return; - const txnMessage = { - type: 'transaction', - txn: toBase64URL(encodeUnsignedTransaction(txn)), - }; - addMessage({ type: 'local', data: txnMessage, timestamp: Date.now() }); - datachannel?.send(JSON.stringify(txnMessage)); - setIsWaitingForSignature(true); - }, [txn, datachannel, isWaitingForSignature]); - useEffect(() => { if (!datachannel || wallet === '') navigate('/'); - }, [datachannel, wallet]); + }, [datachannel, wallet, navigate]); if (accountInfo.data && accountInfo.data.amount === 0) { - return ( - -

Account has no funds

-

{accountInfo.data.address}

-
{JSON.stringify(accountInfo.data, null, 2)}
-
- ); - } - if (isWaitingForSignature || isWaitingForConfirmation) { - return ( - -

- Waiting for {isWaitingForConfirmation ? 'Confirmation' : 'Signature'} -

- -
- ); + return ; } return ( - + <> Send Payment Transaction @@ -145,26 +122,67 @@ export function ConnectedPage() { disabled={!datachannel || accountInfo.isLoading} onClick={async () => { const suggestedParams = await algod.getTransactionParams().do(); - setTxn( - makePaymentTxnWithSuggestedParamsFromObject({ - from, - suggestedParams, - to, - amount, - }), - ); + const txn = makePaymentTxnWithSuggestedParamsFromObject({ + from, + suggestedParams, + to, + amount, + }); + + addTransaction({ + txn: txn, + txId: txn.txID(), + status: 'created', + }); + + const txnMessage = { + type: 'transaction', + txId: txn.txID(), + txn: toBase64URL(encodeUnsignedTransaction(txn)), + }; + addMessage({ + type: 'local', + data: txnMessage, + timestamp: Date.now(), + }); + datachannel?.send(JSON.stringify(txnMessage)); + updateTransaction(txn.txID(), 'sent'); }} > Send Transaction + + Transactions + + + {transactions.map((transaction, i) => ( + + ))} + Messages - {messages.map((message, i) => ( - -
{JSON.stringify(message, null, 2)}
-
- ))} -
+ + {messages.map((message, i) => ( + + ))} + + ); } diff --git a/sites/dapp-ui/src/pages/home.tsx b/sites/dapp-ui/src/pages/home.tsx index 6d384a2..ef7747d 100644 --- a/sites/dapp-ui/src/pages/home.tsx +++ b/sites/dapp-ui/src/pages/home.tsx @@ -33,12 +33,13 @@ export function HomePage() { /> - Get Started (1 of 2) + Get Started - Start by connecting a valid wallet, this is the first step in a three - step process. The connecting wallet receives the current website URL - from the QR Code and submits a verification request to the service. + Start by connecting a valid wallet, the connecting wallet receives the + current website URL from the QR Code and submits a verification + request to the service. Once the service validates the request, both + clients will negotiate a P2P channel to exchange messages. diff --git a/sites/dapp-ui/src/store.ts b/sites/dapp-ui/src/store.ts index 2863c4c..ffd89a2 100644 --- a/sites/dapp-ui/src/store.ts +++ b/sites/dapp-ui/src/store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { Transaction } from 'algosdk'; interface AddressState { address: string; @@ -20,8 +21,44 @@ export const useAddressStore = create< }, ), ); + +export type TransactionSignaturePayload = { + type: 'transaction-signature'; + txId: string; + sig: string; +}; +export type TransactionPayload = { + type: 'transaction'; + txId: string; + txn: string; +}; +// { +// "address": "PNNMCGV3XLEDFEGR7IHSGY5HHGAQQ4OC7H75SPGQUFVTNWV45JVXPRJULY", +// "device": "Pixel 8 Pro", +// "origin": "https://catfish-pro-wolf.ngrok-free.app", +// "id": "AdoMGSqp-ni0udT2e5RafkSJo2Czs0s-Ekr5wB06PIpXIhlG-qfdCyN_riM_enKwZnwQXwrFp3e9IB0VNLg6swM", +// "prevCounter": 2, +// "type": "credential" +// } +export type CredentialPayload = { + type: 'credential'; + id: string; + address: string; + device?: string; + origin: string; + prevCounter: number; +}; + +export type MessagePayload = { + type: 'transaction' | 'credential' | 'transaction-signature'; + // data: TransactionSignature | ; +}; export type Message = { - data: unknown; + data: + | TransactionPayload + | TransactionSignaturePayload + | CredentialPayload + | string; type: 'local' | 'remote'; timestamp: number; }; @@ -37,3 +74,36 @@ export const useMessageStore = create((set) => ({ set((state) => ({ messages: [...state.messages, message] })), clearMessages: () => set({ messages: [] }), })); + +export type LiquidTransaction = { + txn: Transaction; + txId: string; + sig?: string; + status: 'created' | 'sent' | 'signed' | 'submitted' | 'confirmed' | 'failed'; +}; + +interface TransactionStore { + transactions: LiquidTransaction[]; + addTransaction: (txn: LiquidTransaction) => void; + updateTransaction: ( + txId: string, + status: LiquidTransaction['status'], + ) => void; + clearTransactions: () => void; +} + +export const useTransactionStore = create((set) => ({ + transactions: [], + addTransaction: (txn: LiquidTransaction) => + set((state) => ({ transactions: [...state.transactions, txn] })), + updateTransaction: (txId: string, status: LiquidTransaction['status']) => + set((state) => ({ + transactions: state.transactions.map((txn) => { + if (txn.txId === txId) { + return { ...txn, status }; + } + return txn; + }), + })), + clearTransactions: () => set({ transactions: [] }), +}));