From 89dc44ec3701765a28ecb220e3e4671a53ef974b Mon Sep 17 00:00:00 2001 From: samsiegart Date: Tue, 13 Feb 2024 23:51:06 -0800 Subject: [PATCH] feat: use wallet connection component --- ui/package.json | 6 +- ui/src/App.css | 6 +- ui/src/App.tsx | 176 +++++--------------------- ui/src/components/Inventory.tsx | 84 ++++++------ ui/src/components/Trade.tsx | 64 ++++++++-- ui/src/hooks/usePurse.ts | 10 ++ ui/src/installSesLockdown.ts | 7 +- ui/src/providers/Contract.tsx | 42 ++++++ ui/src/store/contract.ts | 8 ++ ui/src/{index.d.ts => types/index.ts} | 4 +- 10 files changed, 202 insertions(+), 205 deletions(-) create mode 100644 ui/src/hooks/usePurse.ts create mode 100644 ui/src/providers/Contract.tsx create mode 100644 ui/src/store/contract.ts rename ui/src/{index.d.ts => types/index.ts} (77%) diff --git a/ui/package.json b/ui/package.json index 3f07b29..79d98a3 100644 --- a/ui/package.json +++ b/ui/package.json @@ -23,6 +23,9 @@ "@agoric/store": "^0.9.2", "@agoric/ui-components": "^0.9.0", "@agoric/web-components": "^0.15.0", + "buffer": "^6.0.3", + "chain-registry": "^1.28.0", + "cosmos-kit": "^2.9.0", "@testing-library/react": "^14.1.2", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", @@ -50,6 +53,7 @@ "prettier": { "trailingComma": "all", "arrowParens": "avoid", - "singleQuote": true + "singleQuote": true, + "tabWidth": 2 } } diff --git a/ui/src/App.css b/ui/src/App.css index b4d6ec7..c7be568 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -99,11 +99,11 @@ input { } .error { - background-color: #E11D48; + background-color: #e11d48; color: #fff; } /* increment/decrement arrows always visible */ -input[type=number]::-webkit-inner-spin-button { - opacity: 1 +input[type='number']::-webkit-inner-spin-button { + opacity: 1; } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8d5a1e5..52c35ca 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,156 +1,38 @@ -import { useEffect } from 'react'; - import './App.css'; -import { - makeAgoricChainStorageWatcher, - AgoricChainStoragePathKind as Kind, -} from '@agoric/rpc'; -import { create } from 'zustand'; -import { - makeAgoricWalletConnection, - suggestChain, -} from '@agoric/web-components'; -import { subscribeLatest } from '@agoric/notifier'; -import { makeCopyBag } from '@agoric/store'; import { Logos } from './components/Logos'; import { Inventory } from './components/Inventory'; import { Trade } from './components/Trade'; +import { ContractProvider } from './providers/Contract'; +import { AgoricProvider } from '@agoric/react-components'; +import { wallets } from 'cosmos-kit'; +import '@agoric/react-components/dist/style.css'; -const { entries, fromEntries } = Object; - -type Wallet = Awaited>; - -const ENDPOINTS = { - RPC: 'http://localhost:26657', - API: 'http://localhost:1317', -}; - -const watcher = makeAgoricChainStorageWatcher(ENDPOINTS.API, 'agoriclocal'); - -interface AppState { - wallet?: Wallet; - offerUpInstance?: unknown; - brands?: Record; - purses?: Array; -} - -const useAppStore = create(() => ({})); - -const setup = async () => { - watcher.watchLatest>( - [Kind.Data, 'published.agoricNames.instance'], - instances => { - console.log('got instances', instances); - useAppStore.setState({ - offerUpInstance: instances.find(([name]) => name === 'offerUp')!.at(1), - }); - }, - ); - - watcher.watchLatest>( - [Kind.Data, 'published.agoricNames.brand'], - brands => { - console.log('Got brands', brands); - useAppStore.setState({ - brands: fromEntries(brands), - }); - }, - ); -}; - -const connectWallet = async () => { - await suggestChain('https://local.agoric.net/network-config'); - const wallet = await makeAgoricWalletConnection(watcher, ENDPOINTS.RPC); - useAppStore.setState({ wallet }); - const { pursesNotifier } = wallet; - for await (const purses of subscribeLatest(pursesNotifier)) { - console.log('got purses', purses); - useAppStore.setState({ purses }); - } -}; - -const makeOffer = (giveValue: bigint, wantChoices: Record) => { - const { wallet, offerUpInstance, brands } = useAppStore.getState(); - if (!offerUpInstance) throw Error('no contract instance'); - if (!(brands && brands.IST && brands.Item)) - throw Error('brands not available'); - - const value = makeCopyBag(entries(wantChoices)); - const want = { Items: { brand: brands.Item, value } }; - const give = { Price: { brand: brands.IST, value: giveValue } }; - - wallet?.makeOffer( - { - source: 'contract', - instance: offerUpInstance, - publicInvitationMaker: 'makeTradeInvitation', - }, - { give, want }, - undefined, - (update: { status: string; data?: unknown }) => { - if (update.status === 'error') { - alert(`Offer error: ${update.data}`); - } - if (update.status === 'accepted') { - alert('Offer accepted'); - } - if (update.status === 'refunded') { - alert('Offer rejected'); - } - }, - ); -}; - -function App() { - useEffect(() => { - setup(); - }, []); - - const { wallet, purses } = useAppStore(({ wallet, purses }) => ({ - wallet, - purses, - })); - const istPurse = purses?.find(p => p.brandPetname === 'IST'); - const itemsPurse = purses?.find(p => p.brandPetname === 'Item'); - - const tryConnectWallet = () => { - connectWallet().catch(err => { - switch (err.message) { - case 'KEPLR_CONNECTION_ERROR_NO_SMART_WALLET': - alert( - 'no smart wallet at that address; try: yarn docker:make print-key', - ); - break; - default: - alert(err.message); - } - }); - }; - +const App = () => { return ( - <> - -

Items Listed on Offer Up

- -
- -
- {wallet && istPurse ? ( - - ) : ( - - )} -
- + + + +

Items Listed on Offer Up

+
+ +
+ +
+
+
); -} +}; export default App; diff --git a/ui/src/components/Inventory.tsx b/ui/src/components/Inventory.tsx index 53dea99..aa8deb0 100644 --- a/ui/src/components/Inventory.tsx +++ b/ui/src/components/Inventory.tsx @@ -1,49 +1,53 @@ +import { ConnectWalletButton, useAgoric } from '@agoric/react-components'; import { stringifyAmountValue } from '@agoric/ui-components'; +import { usePurse } from '../hooks/usePurse'; +import type { CopyBag } from '../types'; -type InventoryProps = { - address: string; - istPurse: Purse; - itemsPurse: Purse; -}; +const Inventory = () => { + const istPurse = usePurse('IST'); + const itemsPurse = usePurse('Item'); + const { walletConnection } = useAgoric(); -const Inventory = ({ address, istPurse, itemsPurse }: InventoryProps) => ( -
-

My Wallet

-
+ return ( +
+

My Wallet

- - {address} - -
- -
-
- IST: - {stringifyAmountValue( - istPurse.currentAmount, - istPurse.displayInfo.assetKind, - istPurse.displayInfo.decimalPlaces, - )} -
-
- Items: - {itemsPurse ? ( -
    - {(itemsPurse.currentAmount.value as CopyBag).payload.map( - ([name, number]) => ( -
  • - {String(number)} {name} -
  • - ), + + {walletConnection && ( +
    +
    + IST: + {istPurse ? ( + stringifyAmountValue( + istPurse.currentAmount, + istPurse.displayInfo.assetKind, + istPurse.displayInfo.decimalPlaces, + ) + ) : ( + Fetching balance... )} -
- ) : ( - 'None' - )} -
+
+
+ Items: + {itemsPurse ? ( +
    + {(itemsPurse.currentAmount.value as CopyBag).payload.map( + ([name, number]) => ( +
  • + {String(number)} {name} +
  • + ), + )} +
+ ) : ( + 'None' + )} +
+
+ )}
- -); + ); +}; export { Inventory }; diff --git a/ui/src/components/Trade.tsx b/ui/src/components/Trade.tsx index 9ce8b87..56be3e9 100644 --- a/ui/src/components/Trade.tsx +++ b/ui/src/components/Trade.tsx @@ -4,6 +4,11 @@ import scrollIcon from '../assets/scroll.png'; import istIcon from '../assets/IST.svg'; import mapIcon from '../assets/map.png'; import potionIcon from '../assets/potionBlue.png'; +import { usePurse } from '../hooks/usePurse'; +import { AgoricWalletConnection, useAgoric } from '@agoric/react-components'; +import { makeCopyBag } from '@agoric/store'; +import { useContractStore } from '../store/contract'; +import type { Purse } from '../types'; const { entries, values } = Object; const sum = (xs: bigint[]) => xs.reduce((acc, next) => acc + next, 0n); @@ -26,6 +31,42 @@ const parseValue = (numeral: string, purse: Purse): bigint => { return BigInt(num); }; +const makeOffer = ( + wallet: AgoricWalletConnection, + giveValue: bigint, + wantChoices: Record, +) => { + const { instance, brands } = useContractStore.getState(); + if (!instance) throw Error('no contract instance'); + if (!(brands && brands.IST && brands.Item)) + throw Error('brands not available'); + + const value = makeCopyBag(entries(wantChoices)); + const want = { Items: { brand: brands.Item, value } }; + const give = { Price: { brand: brands.IST, value: giveValue } }; + + wallet?.makeOffer( + { + source: 'contract', + instance, + publicInvitationMaker: 'makeTradeInvitation', + }, + { give, want }, + undefined, + (update: { status: string; data?: unknown }) => { + if (update.status === 'error') { + alert(`Offer error: ${update.data}`); + } + if (update.status === 'accepted') { + alert('Offer accepted'); + } + if (update.status === 'refunded') { + alert('Offer rejected'); + } + }, + ); +}; + const Item = ({ icon, coinIcon, @@ -62,16 +103,13 @@ const Item = ({ ); -type TradeProps = { - makeOffer: (giveValue: bigint, wantChoices: Record) => void; - istPurse: Purse; - walletConnected: boolean; -}; - -// TODO: IST displayInfo is available in vbankAsset or boardAux -const Trade = ({ makeOffer, istPurse, walletConnected }: TradeProps) => { +const Trade = () => { + const istPurse = usePurse('IST'); + const { brands, instance } = useContractStore(); const [giveValue, setGiveValue] = useState(terms.price); const [choices, setChoices] = useState({ map: 1n, scroll: 2n }); + const { walletConnection } = useAgoric(); + const changeChoice = (ev: FormEvent) => { if (!ev.target) return; const elt = ev.target as HTMLInputElement; @@ -120,7 +158,7 @@ const Trade = ({ makeOffer, istPurse, walletConnected }: TradeProps) => { } label="IST" onChange={ev => - setGiveValue(parseValue(ev?.target?.value, istPurse)) + istPurse && setGiveValue(parseValue(ev?.target?.value, istPurse)) } inputClassName={giveValue >= terms.price ? 'ok' : 'error'} inputStep="0.01" @@ -128,10 +166,14 @@ const Trade = ({ makeOffer, istPurse, walletConnected }: TradeProps) => {
- {walletConnected && ( - + ) : ( + <> )}
diff --git a/ui/src/hooks/usePurse.ts b/ui/src/hooks/usePurse.ts new file mode 100644 index 0000000..9646300 --- /dev/null +++ b/ui/src/hooks/usePurse.ts @@ -0,0 +1,10 @@ +import { useAgoric } from '@agoric/react-components'; +import type { Purse } from '../types'; + +export const usePurse = (brandPetname: string) => { + const { purses } = useAgoric(); + + return purses?.find(p => p.brandPetname === brandPetname) as + | Purse + | undefined; +}; diff --git a/ui/src/installSesLockdown.ts b/ui/src/installSesLockdown.ts index 2eb8e6e..a8afbc9 100644 --- a/ui/src/installSesLockdown.ts +++ b/ui/src/installSesLockdown.ts @@ -1,9 +1,9 @@ import 'ses'; // adds lockdown, harden, and Compartment import '@endo/eventual-send/shim.js'; // adds support needed by E +import { Buffer } from 'buffer'; const consoleTaming = import.meta.env.DEV ? 'unsafe' : 'safe'; -// @ts-expect-error global lockdown({ errorTaming: 'unsafe', overrideTaming: 'severe', @@ -11,3 +11,8 @@ lockdown({ }); Error.stackTraceLimit = Infinity; + +globalThis.Buffer = Buffer; + +// @ts-expect-error Add process to context for cosmos-kit +globalThis.process = { env: import.meta.env }; diff --git a/ui/src/providers/Contract.tsx b/ui/src/providers/Contract.tsx new file mode 100644 index 0000000..881174d --- /dev/null +++ b/ui/src/providers/Contract.tsx @@ -0,0 +1,42 @@ +import { PropsWithChildren, useEffect } from 'react'; +import { + type ChainStorageWatcher, + AgoricChainStoragePathKind as Kind, +} from '@agoric/rpc'; +import { useAgoric } from '@agoric/react-components'; +import { useContractStore } from '../store/contract'; + +const { fromEntries } = Object; + +const watchContract = (watcher: ChainStorageWatcher) => { + watcher.watchLatest>( + [Kind.Data, 'published.agoricNames.instance'], + instances => { + console.log('Got instances', instances); + useContractStore.setState({ + instance: instances.find(([name]) => name === 'offerUp')!.at(1), + }); + }, + ); + + watcher.watchLatest>( + [Kind.Data, 'published.agoricNames.brand'], + brands => { + console.log('Got brands', brands); + useContractStore.setState({ + brands: fromEntries(brands), + }); + }, + ); +}; + +export const ContractProvider = ({ children }: PropsWithChildren) => { + const { chainStorageWatcher } = useAgoric(); + useEffect(() => { + if (chainStorageWatcher) { + watchContract(chainStorageWatcher); + } + }, [chainStorageWatcher]); + + return <>{children}; +}; diff --git a/ui/src/store/contract.ts b/ui/src/store/contract.ts new file mode 100644 index 0000000..160b05c --- /dev/null +++ b/ui/src/store/contract.ts @@ -0,0 +1,8 @@ +import { create } from 'zustand'; + +interface ContractState { + instance?: unknown; + brands?: Record; +} + +export const useContractStore = create(() => ({})); diff --git a/ui/src/index.d.ts b/ui/src/types/index.ts similarity index 77% rename from ui/src/index.d.ts rename to ui/src/types/index.ts index d2ee4b3..0d303c3 100644 --- a/ui/src/index.d.ts +++ b/ui/src/types/index.ts @@ -1,8 +1,8 @@ -interface CopyBag { +export interface CopyBag { payload: Array<[T, bigint]>; } -interface Purse { +export interface Purse { brand: unknown; brandPetname: string; currentAmount: {