diff --git a/src/components/AppBar.tsx b/src/components/AppBar.tsx index 4d5c8c00..b08b27a2 100644 --- a/src/components/AppBar.tsx +++ b/src/components/AppBar.tsx @@ -1,5 +1,5 @@ +import * as React from 'react'; import { useState } from 'react'; - import { makeStyles, useTheme } from '@mui/styles'; import HelpIcon from '@mui/icons-material/HelpOutline'; import SettingsIcon from '@mui/icons-material/SettingsOutlined'; diff --git a/src/components/SmartWalletConnection.tsx b/src/components/SmartWalletConnection.tsx index ef1d255c..a6e142c2 100644 --- a/src/components/SmartWalletConnection.tsx +++ b/src/components/SmartWalletConnection.tsx @@ -52,7 +52,7 @@ export const useProvisionPoolMetrics = (unserializer, leader) => { break; } console.log('provisionPoolData', value); - setData(value); + setData(value as ProvisionPoolMetrics); } }; fetchData().catch(e => @@ -172,6 +172,7 @@ const SmartWalletConnection = ({ makeFollower(`:published.${path}`, leader, { unserializer: context.fromMyWallet, }); + const bridge = makeWalletBridgeFromFollowers( { chainId: keplrConnection.chainId, @@ -184,6 +185,8 @@ const SmartWalletConnection = ({ makeFollower(`:beansOwing.${publicAddress}`, leader, { unserializer: { unserialize: data => data }, }), + followPublished('agoricNames.vbankAsset'), + followPublished('agoricNames.brand'), keplrConnection, // @ts-expect-error xxx backendError, diff --git a/src/service/Offers.ts b/src/service/Offers.ts index a4fe16cc..d3bc60e4 100644 --- a/src/service/Offers.ts +++ b/src/service/Offers.ts @@ -5,7 +5,6 @@ import { makeAsyncIterableFromNotifier, } from '@agoric/notifier'; import { E } from '@endo/eventual-send'; - import { loadOffers as load, removeOffer as remove, @@ -17,11 +16,11 @@ import { import type { SmartWalletKey } from '../store/Dapps'; import type { OfferSpec, OfferStatus } from '@agoric/smart-wallet/src/offers'; -import { Marshal } from '@endo/marshal'; - +import type { Marshal } from '@endo/marshal'; import type { Notifier } from '@agoric/notifier/src/types'; -import { Petname } from '@agoric/smart-wallet/src/types'; -import { Brand } from '@agoric/ertp/src/types'; +import type { Petname } from '@agoric/smart-wallet/src/types'; +import type { Brand } from '@agoric/ertp/src/types'; +import { AmountMath } from '@agoric/ertp'; export const getOfferService = ( smartWalletKey: SmartWalletKey, @@ -30,7 +29,7 @@ export const getOfferService = ( boardIdMarshaller: Marshal, ) => { const offers = new Map(); - const { notifier, updater } = makeNotifierKit(); + const { notifier, updater } = makeNotifierKit(); const broadcastUpdates = () => updater.updateState([...offers.values()]); const addSpendActionAndInstancePetname = async ( @@ -41,33 +40,38 @@ export const getOfferService = ( id, instanceHandle, publicInvitationMaker, - proposalTemplate: { give, want }, + proposalTemplate: { give: giveTemplate, want: wantTemplate }, } = offer; - const mapPursePetnamesToBrands = paymentProposals => - Object.fromEntries( + const convertProposals = async paymentProposals => { + const entries = await Promise.all( Object.entries(paymentProposals).map( // @ts-expect-error - ([kw, { pursePetname, value }]) => { - const brand = pursePetnameToBrand.get(pursePetname); - if (!brand) { + async ([kw, { pursePetname, value, amount: serializedAmount }]) => { + if (!serializedAmount && !pursePetnameToBrand.get(pursePetname)) { return []; } - return [ - kw, - { - brand, - value: BigInt(value), - }, - ]; + + /// TODO: test e2e with dapp inter once feasible. + const amount = serializedAmount + ? await E(boardIdMarshaller).unserialize(serializedAmount) + : AmountMath.make( + pursePetnameToBrand.get(pursePetname), + BigInt(value), + ); + + return [kw, amount]; }, ), ); + return Object.fromEntries(entries); + }; - const instance = await E(boardIdMarshaller).unserialize(instanceHandle); - const { - slots: [instanceBoardId], - } = await E(boardIdMarshaller).serialize(instance); + const [instance, give, want] = await Promise.all([ + E(boardIdMarshaller).unserialize(instanceHandle), + convertProposals(giveTemplate), + convertProposals(wantTemplate), + ]); const offerForAction: OfferSpec = { id, @@ -77,17 +81,25 @@ export const getOfferService = ( publicInvitationMaker, }, proposal: { - give: mapPursePetnamesToBrands(give), - want: mapPursePetnamesToBrands(want), + give, + want, }, }; - const spendAction = await E(boardIdMarshaller).serialize( - harden({ - method: 'executeOffer', - offer: offerForAction, - }), - ); + const [ + { + slots: [instanceBoardId], + }, + spendAction, + ] = await Promise.all([ + E(boardIdMarshaller).serialize(instance), + E(boardIdMarshaller).serialize( + harden({ + method: 'executeOffer', + offer: offerForAction, + }), + ), + ]); return { ...offer, @@ -125,29 +137,30 @@ export const getOfferService = ( chainOffersNotifier, )) { console.log('offerStatus', { status, offers }); - const oldOffer = offers.get(status?.id); + const id = status && Number(status?.id); + const oldOffer = offers.get(id); if (!oldOffer) { console.warn('Update for unknown offer, doing nothing.'); } else { if (status.error !== undefined) { - offers.set(status.id, { + offers.set(id, { ...oldOffer, - id: status.id, + id, status: OfferUIStatus.rejected, error: `${status.error}`, }); - remove(smartWalletKey, status.id); + remove(smartWalletKey, id); } else if (status.numWantsSatisfied !== undefined) { - offers.set(status.id, { + offers.set(id, { ...oldOffer, - id: status.id, + id, status: OfferUIStatus.accepted, }); - remove(smartWalletKey, status.id); + remove(smartWalletKey, id); } else if (status.numWantsSatisfied === undefined) { - offers.set(status.id, { + offers.set(id, { ...oldOffer, - id: status.id, + id, status: OfferUIStatus.pending, }); upsertOffer({ ...oldOffer, status: OfferUIStatus.pending }); diff --git a/src/store/Offers.ts b/src/store/Offers.ts index 9244b227..3f5ce928 100644 --- a/src/store/Offers.ts +++ b/src/store/Offers.ts @@ -27,6 +27,7 @@ export type Offer = { status: OfferUIStatus; instancePetname?: string; spendAction?: string; + error?: unknown; } & OfferConfig; export const loadOffers = ({ chainId, address }: SmartWalletKey) => diff --git a/src/util/WalletBackendAdapter.ts b/src/util/WalletBackendAdapter.ts index 07f979d5..7a87e642 100644 --- a/src/util/WalletBackendAdapter.ts +++ b/src/util/WalletBackendAdapter.ts @@ -29,8 +29,11 @@ import { KeplrUtils } from '../contexts/Provider.jsx'; import type { PurseInfo } from '@agoric/web-components/src/keplr-connection/fetchCurrent'; import { HttpEndpoint } from '@cosmjs/tendermint-rpc'; import type { ValueFollowerElement } from '@agoric/casting/src/types'; +import { queryBankBalances } from './queryBankBalances'; +import type { Coin } from '@cosmjs/stargate'; const newId = kind => `${kind}${Math.random()}`; +const POLL_INTERVAL_MS = 6000; export type BackendSchema = { actions: object; @@ -108,11 +111,22 @@ export const makeBackendFromWalletBridge = ( const cancel = e => { backendUpdater.fail(e); + walletBridge.cleanup(); }; return { backendIt, cancel }; }; +type VbankInfo = { + brand: Brand; + displayInfo: DisplayInfo<'nat'>; + issuerName: string; +}; + +type VbankUpdate = [string, VbankInfo][]; + +type AgoricBrandsUpdate = [string, Brand][]; + export const makeWalletBridgeFromFollowers = ( smartWalletKey: SmartWalletKey, rpc: HttpEndpoint, @@ -120,6 +134,8 @@ export const makeWalletBridgeFromFollowers = ( currentFollower: ValueFollower, updateFollower: ValueFollower, beansOwingFollower: ValueFollower, + vbankAssetsFollower: ValueFollower, + agoricBrandsFollower: ValueFollower, keplrConnection: KeplrUtils, errorHandler = e => { // Make an unhandled rejection. @@ -127,6 +143,11 @@ export const makeWalletBridgeFromFollowers = ( }, firstCallback: () => void | undefined = () => {}, ) => { + let isHalted = false; + let isBankLoaded = false; + let isSmartWalletLoaded = false; + let isOfferServiceStarted = false; + const notifiers = { getPursesNotifier: 'purses', getContactsNotifier: 'contacts', @@ -142,14 +163,12 @@ export const makeWalletBridgeFromFollowers = ( ]), ); - const { notifier: beansOwingNotifier, updater: beansOwingUpdater } = - makeNotifierKit(null); - // We assume just one cosmos purse per brand. const brandToPurse = new Map(); const pursePetnameToBrand = new Map(); const updatePurses = () => { + console.debug('brandToPurse map', brandToPurse); const purses = [] as PurseInfo[]; for (const [brand, purse] of brandToPurse.entries()) { if (purse.currentAmount && purse.brandPetname) { @@ -159,6 +178,13 @@ export const makeWalletBridgeFromFollowers = ( } } notifierKits.purses.updater.updateState(harden(purses)); + + // Make sure the offer service has all purses from both bank and smart + // wallet chainstorage. + if (!isOfferServiceStarted && isBankLoaded && isSmartWalletLoaded) { + isOfferServiceStarted = true; + offerService.start(pursePetnameToBrand); + } }; const signSpendAction = async (data: string) => { @@ -188,15 +214,80 @@ export const makeWalletBridgeFromFollowers = ( marshaller, ); + const { notifier: beansOwingNotifier, updater: beansOwingUpdater } = + makeNotifierKit(null); + const watchBeansOwing = async () => { for await (const { value } of iterateLatest(beansOwingFollower)) { + if (isHalted) return; beansOwingUpdater.updateState(Number(value)); } }; + // Infers purse balances from cosmos bank module balances since purses are + // lazily instantiated in the smart wallet. + const watchChainBalances = () => { + let vbankAssets: VbankUpdate; + let bank: Coin[]; + + const possiblyUpdateBankPurses = () => { + if (!vbankAssets || !bank) return; + + const bankMap = new Map( + bank.map(({ denom, amount }) => [denom, amount]), + ); + + vbankAssets.forEach(([denom, info]) => { + // Show the vbank asset as a purse with 0 balance if the user doesn't + // have any. This way it will show up on their asset list with the + // deposit action available. + const amount = bankMap.get(denom) ?? 0n; + + const purseInfo: PurseInfo = { + brand: info.brand, + currentAmount: AmountMath.make(info.brand, BigInt(amount)), + brandPetname: info.issuerName, + pursePetname: info.issuerName, + displayInfo: info.displayInfo, + }; + brandToPurse.set(info.brand, purseInfo); + }); + + isBankLoaded = true; + updatePurses(); + }; + + const watchBank = async () => { + if (isHalted) return; + bank = await queryBankBalances(keplrConnection.address, rpc); + possiblyUpdateBankPurses(); + setTimeout(watchBank, POLL_INTERVAL_MS); + }; + + const watchVbankAssets = async () => { + for await (const { value } of iterateLatest(vbankAssetsFollower)) { + if (isHalted) return; + vbankAssets = value; + possiblyUpdateBankPurses(); + } + }; + + void watchVbankAssets(); + void watchBank(); + }; + + const fetchAgoricBrands = async () => { + for await (const { value } of iterateLatest(agoricBrandsFollower)) { + // Invert so we have a map of brands to petnames. + return new Map((value as AgoricBrandsUpdate).map(([k, v]) => [v, k])); + } + }; + const fetchCurrent = async () => { await assertHasData(currentFollower); void watchBeansOwing(); + watchChainBalances(); + const latestIterable = await E(currentFollower).getLatestIterable(); const iterator = latestIterable[Symbol.asyncIterator](); const latest = await iterator.next(); @@ -210,13 +301,31 @@ export const makeWalletBridgeFromFollowers = ( } const currentEl: ValueFollowerElement = latest.value; const wallet = currentEl.value; - console.log('wallet current', wallet); + console.debug('wallet current', wallet); + + const agoricBrands = await fetchAgoricBrands(); + assert(agoricBrands, 'Failed to fetch agoric brands'); + for (const purse of wallet.purses) { - console.debug('registering purse', purse); - const brandDescriptor = wallet.brands.find( - bd => purse.brand === bd.brand, - ); - assert(brandDescriptor, `missing descriptor for brand ${purse.brand}`); + // Non 'set' amounts need to be fetched from vbank to know their + // decimalPlaces, so we can skip them. Currently this means all assets + // except zoe invites are read via `watchChainBalances`. + // + // If we ever add non 'set' amount purses that aren't in the vbank, it's + // not currently possible to read their decimalPlaces, so this code + // will need updating. + if (!Array.isArray(purse.balance.value)) { + console.debug('skipping non-set amount', purse.balance.value); + continue; + } + if (!agoricBrands.has(purse.brand)) { + console.warn('skipping unknown brand', purse.brand); + continue; + } + const brandDescriptor = { + petname: agoricBrands.get(purse.brand) as Petname, + displayInfo: { assetKind: 'set' }, + }; const purseInfo: PurseInfo = { brand: purse.brand, currentAmount: purse.balance, @@ -226,9 +335,8 @@ export const makeWalletBridgeFromFollowers = ( }; brandToPurse.set(purse.brand, purseInfo); } - console.debug('brandToPurse map', brandToPurse); + isSmartWalletLoaded = true; updatePurses(); - offerService.start(pursePetnameToBrand); return currentEl.blockHeight; }; @@ -340,6 +448,7 @@ export const makeWalletBridgeFromFollowers = ( makeEmptyPurse, addContact, addIssuer, + cleanup: () => (isHalted = true), }); return walletBridge; diff --git a/src/util/queryBankBalances.ts b/src/util/queryBankBalances.ts new file mode 100644 index 00000000..0e264f1e --- /dev/null +++ b/src/util/queryBankBalances.ts @@ -0,0 +1,23 @@ +import { QueryClient, createProtobufRpcClient } from '@cosmjs/stargate'; +import { Tendermint34Client } from '@cosmjs/tendermint-rpc'; +import { QueryClientImpl } from 'cosmjs-types/cosmos/bank/v1beta1/query'; +import type { Coin } from '@cosmjs/stargate'; +import type { HttpEndpoint } from '@cosmjs/tendermint-rpc'; + +// UNTIL casting supports this query. This is sub-optimal because it doesn't +// support batching, load-balancing, or proofs. +export const queryBankBalances = async ( + address: string, + rpc: HttpEndpoint, +): Promise => { + const tendermint = await Tendermint34Client.connect(rpc); + const queryClient = new QueryClient(tendermint); + const rpcClient = createProtobufRpcClient(queryClient); + const bankQueryService = new QueryClientImpl(rpcClient); + + const { balances } = await bankQueryService.AllBalances({ + address, + }); + + return balances; +}; diff --git a/src/util/querySwingsetParams.ts b/src/util/querySwingsetParams.ts index 7454ee3b..ac81c624 100644 --- a/src/util/querySwingsetParams.ts +++ b/src/util/querySwingsetParams.ts @@ -15,7 +15,7 @@ import { */ export const querySwingsetParams = async ( endpoint: HttpEndpoint, -): QueryParamsResponse => { +): Promise => { const http = new HttpClient(endpoint); const trpc = await Tendermint34Client.create(http); const base = QueryClient.withExtensions(trpc); diff --git a/yarn.lock b/yarn.lock index bdaa6312..5854ccbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12164,7 +12164,7 @@ rollup@^2.43.1: optionalDependencies: fsevents "~2.3.2" -rollup@endojs/endo#rollup-2.7.1-patch-1, "rollup@github:endojs/endo#rollup-2.7.1-patch-1": +rollup@endojs/endo#rollup-2.7.1-patch-1: version "2.70.1-endo.1" resolved "https://codeload.github.com/endojs/endo/tar.gz/54060e784a4dbe77b6692f17344f4d84a198530d" optionalDependencies: