From 2fd24cd41ce79b6705108e772af260f04c67c8b8 Mon Sep 17 00:00:00 2001 From: Turadg Aleahmad Date: Thu, 30 Nov 2023 09:24:21 -0800 Subject: [PATCH] chore(types): PromiseWatcher refactor: refactor smartWallet to use watchedPromises pulled offers.js and payments.js into smartWallet.js as they shared plenty of state that needs to be durable in order to be callable from the watchedPromise. build an upgrade proposal; tested in https://github.com/Agoric/agoric-3-proposals/pull/34 --- .../boot/test/bootstrapTests/liquidation.ts | 41 +- .../bootstrapTests/test-vaults-integration.ts | 16 +- .../test-walletSurvivesZoeRestart.ts | 288 ++--------- .../boot/test/bootstrapTests/walletFactory.ts | 51 ++ .../inter-protocol/src/price/roundsManager.js | 4 +- .../smartWallet/test-oracle-integration.js | 3 +- .../test/smartWallet/test-psm-integration.js | 7 +- packages/smart-wallet/package.json | 1 + packages/smart-wallet/src/offerWatcher.js | 243 +++++++++ packages/smart-wallet/src/offers.js | 172 ------- packages/smart-wallet/src/payments.js | 89 ---- .../upgrade-wallet-factory2-proposal.js | 58 +++ .../src/proposals/upgrade-wallet-factory2.js | 29 ++ packages/smart-wallet/src/smartWallet.js | 476 +++++++++++++----- packages/smart-wallet/src/utils.js | 4 +- packages/smart-wallet/src/walletFactory.js | 2 +- .../smart-wallet/test/gameAssetContract.js | 2 +- packages/smart-wallet/test/test-addAsset.js | 21 +- 18 files changed, 818 insertions(+), 689 deletions(-) create mode 100644 packages/boot/test/bootstrapTests/walletFactory.ts create mode 100644 packages/smart-wallet/src/offerWatcher.js delete mode 100644 packages/smart-wallet/src/payments.js create mode 100644 packages/smart-wallet/src/proposals/upgrade-wallet-factory2-proposal.js create mode 100644 packages/smart-wallet/src/proposals/upgrade-wallet-factory2.js diff --git a/packages/boot/test/bootstrapTests/liquidation.ts b/packages/boot/test/bootstrapTests/liquidation.ts index e7ec6ce39ccc..9649e7d819b0 100644 --- a/packages/boot/test/bootstrapTests/liquidation.ts +++ b/packages/boot/test/bootstrapTests/liquidation.ts @@ -3,18 +3,13 @@ import { SECONDS_PER_HOUR, SECONDS_PER_MINUTE, } from '@agoric/inter-protocol/src/proposals/econ-behaviors.js'; -import { - AgoricNamesRemotes, - makeAgoricNamesRemotesFromFakeStorage, -} from '@agoric/vats/tools/board-utils.js'; import { Offers } from '@agoric/inter-protocol/src/clientSupport.js'; import type { ExecutionContext } from 'ava'; -import { makeSwingsetTestKit } from '../../tools/supports.ts'; import { makeGovernanceDriver, makePriceFeedDriver, - makeWalletFactoryDriver, } from '../../tools/drivers.ts'; +import { makeWalletFactoryContext } from './walletFactory.ts'; export type LiquidationSetup = { vaults: { @@ -65,35 +60,13 @@ export const likePayouts = ({ Bid, Collateral }) => ({ export const makeLiquidationTestContext = async t => { console.time('DefaultTestContext'); - const swingsetTestKit = await makeSwingsetTestKit(t.log, 'bundles/vaults', { - configSpecifier: '@agoric/vm-config/decentral-main-vaults-config.json', - }); - - const { runUtils, storage } = swingsetTestKit; - console.timeLog('DefaultTestContext', 'swingsetTestKit'); - const { EV } = runUtils; - - // Wait for ATOM to make it into agoricNames - await EV.vat('bootstrap').consumeItem('vaultFactoryKit'); - console.timeLog('DefaultTestContext', 'vaultFactoryKit'); - - // has to be late enough for agoricNames data to have been published - const agoricNamesRemotes: AgoricNamesRemotes = - makeAgoricNamesRemotesFromFakeStorage(swingsetTestKit.storage); - const refreshAgoricNamesRemotes = () => { - Object.assign( - agoricNamesRemotes, - makeAgoricNamesRemotesFromFakeStorage(swingsetTestKit.storage), - ); - }; - agoricNamesRemotes.brand.ATOM || Fail`ATOM missing from agoricNames`; - console.timeLog('DefaultTestContext', 'agoricNamesRemotes'); - - const walletFactoryDriver = await makeWalletFactoryDriver( - runUtils, - storage, + const { + swingsetTestKit, agoricNamesRemotes, - ); + refreshAgoricNamesRemotes, + walletFactoryDriver, + } = await makeWalletFactoryContext(t); + console.timeLog('DefaultTestContext', 'walletFactoryDriver'); const governanceDriver = await makeGovernanceDriver( diff --git a/packages/boot/test/bootstrapTests/test-vaults-integration.ts b/packages/boot/test/bootstrapTests/test-vaults-integration.ts index eda3c165a98f..9d66c9a6f3b3 100644 --- a/packages/boot/test/bootstrapTests/test-vaults-integration.ts +++ b/packages/boot/test/bootstrapTests/test-vaults-integration.ts @@ -13,6 +13,7 @@ import { } from '@agoric/vats/tools/board-utils.js'; import type { TestFn } from 'ava'; import { ParamChangesOfferArgs } from '@agoric/inter-protocol/src/econCommitteeCharter.js'; + import { makeSwingsetTestKit } from '../../tools/supports.ts'; import { makeWalletFactoryDriver } from '../../tools/drivers.ts'; @@ -136,6 +137,8 @@ test('adjust balances', async t => { }); }); +// This test isn't marked .serial, but it depends on previous tests. + test('close vault', async t => { const { walletFactoryDriver } = t.context; @@ -151,7 +154,8 @@ test('close vault', async t => { }); t.like(wd.getLatestUpdateRecord(), { updated: 'offerStatus', - status: { id: 'open-vault', numWantsSatisfied: 1 }, + status: { id: 'open-vault', result: 'UNPUBLISHED', numWantsSatisfied: 1 }, + error: undefined, }); t.log('try giving more than is available in the purse/vbank'); await t.throwsAsync( @@ -171,6 +175,7 @@ test('close vault', async t => { const message = 'Offer {"brand":"[Alleged: IST brand]","value":"[1n]"} is not sufficient to pay off debt {"brand":"[Alleged: IST brand]","value":"[5025000n]"}'; + await t.throwsAsync( wd.executeOfferMaker( Offers.vaults.CloseVault, @@ -181,10 +186,9 @@ test('close vault', async t => { }, 'open-vault', ), - { - message, - }, + { message }, ); + t.like(wd.getLatestUpdateRecord(), { updated: 'offerStatus', status: { @@ -204,10 +208,13 @@ test('close vault', async t => { }, 'open-vault', ); + t.like(wd.getLatestUpdateRecord(), { updated: 'offerStatus', status: { id: 'close-well', + error: undefined, + numWantsSatisfied: 1, result: 'your vault is closed, thank you for your business', // funds are returned payouts: likePayouts(giveCollateral, 0), @@ -226,6 +233,7 @@ test('open vault with insufficient funds gives helpful error', async t => { const wantMinted = giveCollateral * 100; const message = 'Proposed debt {"brand":"[Alleged: IST brand]","value":"[904500000n]"} exceeds max {"brand":"[Alleged: IST brand]","value":"[63462857n]"} for {"brand":"[Alleged: ATOM brand]","value":"[9000000n]"} collateral'; + await t.throwsAsync( wd.executeOfferMaker(Offers.vaults.OpenVault, { offerId: 'open-vault', diff --git a/packages/boot/test/bootstrapTests/test-walletSurvivesZoeRestart.ts b/packages/boot/test/bootstrapTests/test-walletSurvivesZoeRestart.ts index c1fef632c275..e3df8866cc0a 100644 --- a/packages/boot/test/bootstrapTests/test-walletSurvivesZoeRestart.ts +++ b/packages/boot/test/bootstrapTests/test-walletSurvivesZoeRestart.ts @@ -1,22 +1,19 @@ /** @file Bootstrap test of liquidation across multiple collaterals */ import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; -import { NonNullish } from '@agoric/assert'; import process from 'process'; import type { ExecutionContext, TestFn } from 'ava'; -import type { ScheduleNotification } from '@agoric/inter-protocol/src/auction/scheduler.js'; + import { BridgeHandler } from '@agoric/vats'; +import { Offers } from '@agoric/inter-protocol/src/clientSupport.js'; import { LiquidationTestContext, - likePayouts, makeLiquidationTestContext, - scale6, LiquidationSetup, } from './liquidation.ts'; const test = anyTest as TestFn; -//#region Product spec const setup: LiquidationSetup = { vaults: [ { @@ -24,30 +21,12 @@ const setup: LiquidationSetup = { ist: 100, debt: 100.5, }, - { - atom: 15, - ist: 103, - debt: 103.515, - }, - { - atom: 15, - ist: 105, - debt: 105.525, - }, ], bids: [ { give: '80IST', discount: 0.1, }, - { - give: '90IST', - price: 9.0, - }, - { - give: '150IST', - discount: 0.15, - }, ], price: { starting: 12.34, @@ -65,96 +44,30 @@ const setup: LiquidationSetup = { }, }; -const outcome = { - bids: [ - { - payouts: { - Bid: 0, - Collateral: 8.897786, - }, - }, - { - payouts: { - Bid: 0, - Collateral: 10.01001, - }, - }, - { - payouts: { - Bid: 10.46, - Collateral: 16.432903, - }, - }, - ], - reserve: { - allocations: { - ATOM: 0.309852, - STARS: 0.309852, - }, - shortfall: 0, - }, - vaultsSpec: [ - { - locked: 3.373, - }, - { - locked: 3.024, - }, - { - locked: 2.792, - }, - ], - // TODO match spec https://github.com/Agoric/agoric-sdk/issues/7837 - vaultsActual: [ - { - locked: 3.525747, - }, - { - locked: 3.181519, - }, - { - locked: 2.642185, - }, - ], -} as const; -//#endregion - test.before(async t => { t.context = await makeLiquidationTestContext(t); }); + test.after.always(t => { return t.context.shutdown && t.context.shutdown(); }); -// Reference: Flow 1 from https://github.com/Agoric/agoric-sdk/issues/7123 -const checkFlow1 = async ( - t: ExecutionContext, - { - collateralBrandKey, - managerIndex, - }: { collateralBrandKey: string; managerIndex: number }, - _expected: any, -) => { +test.serial('wallet survives zoe null upgrade', async t => { // fail if there are any unhandled rejections process.on('unhandledRejection', (error: Error) => { t.fail(error.message); }); + const collateralBrandKey = 'ATOM'; + const managerIndex = 0; + + const { walletFactoryDriver, setupVaults, controller, buildProposal } = + t.context; - const { - advanceTimeBy, - advanceTimeTo, - check, - priceFeedDrivers, - readLatest, - walletFactoryDriver, - setupVaults, - placeBids, - controller, - buildProposal, - } = t.context; const { EV } = t.context.runUtils; - const buildAndExecuteProposal = async packageSpec => { + const buyer = await walletFactoryDriver.provideSmartWallet('agoric1buyer'); + + const buildAndExecuteProposal = async (packageSpec: string) => { const proposal = await buildProposal(packageSpec); for await (const bundle of proposal.bundles) { @@ -172,164 +85,31 @@ const checkFlow1 = async ( await EV(coreEvalBridgeHandler).fromBridge(bridgeMessage); }; - const metricsPath = `published.vaultFactory.managers.manager${managerIndex}.metrics`; - await setupVaults(collateralBrandKey, managerIndex, setup); - const buyer = await walletFactoryDriver.provideSmartWallet('agoric1buyer'); - await placeBids(collateralBrandKey, 'agoric1buyer', setup); - - { - // --------------- - // Change price to trigger liquidation - // --------------- - - await priceFeedDrivers[collateralBrandKey].setPrice(9.99); - - // check nothing liquidating yet - const liveSchedule: ScheduleNotification = readLatest( - 'published.auction.schedule', - ); - t.is(liveSchedule.activeStartTime, null); - t.like(readLatest(metricsPath), { - numActiveVaults: setup.vaults.length, - numLiquidatingVaults: 0, - }); - - // advance time to start an auction - console.log(collateralBrandKey, 'step 1 of 10'); - await advanceTimeTo(NonNullish(liveSchedule.nextDescendingStepTime)); - t.like(readLatest(metricsPath), { - numActiveVaults: 0, - numLiquidatingVaults: setup.vaults.length, - liquidatingCollateral: { - value: scale6(setup.auction.start.collateral), - }, - liquidatingDebt: { value: scale6(setup.auction.start.debt) }, - lockedQuote: null, - }); - - console.log(collateralBrandKey, 'step 2 of 10'); - await advanceTimeBy(3, 'minutes'); - t.like(readLatest(`published.auction.book${managerIndex}`), { - collateralAvailable: { value: scale6(setup.auction.start.collateral) }, - startCollateral: { value: scale6(setup.auction.start.collateral) }, - startProceedsGoal: { value: scale6(setup.auction.start.debt) }, - }); - - console.log(collateralBrandKey, 'step 3 of 10'); - await advanceTimeBy(3, 'minutes'); - - console.log(collateralBrandKey, 'step 4 of 10'); - await advanceTimeBy(3, 'minutes'); - // XXX updates for bid1 and bid2 are appended in the same turn so readLatest gives bid2 - // NB: console output shows 8897786n payout which matches spec 8.897ATOM - // t.like(readLatest('published.wallet.agoric1buyer'), { - // status: { - // id: `${collateralBrandKey}-bid1`, - // payouts: { - // Bid: { value: 0n }, - // Collateral: { value: scale6(outcome.bids[0].payouts.Collateral) }, - // }, - // }, - // }); - - t.like(readLatest('published.wallet.agoric1buyer'), { - status: { - id: `${collateralBrandKey}-bid2`, - payouts: likePayouts(outcome.bids[1].payouts), - }, - }); + // restart Zoe - console.log(collateralBrandKey, 'step 5 of 10'); - await advanceTimeBy(3, 'minutes'); + // /////// Upgrading //////////////////////////////// + await buildAndExecuteProposal('@agoric/builders/scripts/vats/upgrade-zoe.js'); - console.log(collateralBrandKey, 'step 6 of 10'); - await advanceTimeBy(3, 'minutes'); - t.like(readLatest(`published.auction.book${managerIndex}`), { - collateralAvailable: { value: 9659301n }, - }); - - console.log(collateralBrandKey, 'step 7 of 10'); - await advanceTimeBy(3, 'minutes'); - - console.log(collateralBrandKey, 'step 8 of 10'); - await advanceTimeBy(3, 'minutes'); - - console.log(collateralBrandKey, 'step 9 of 10'); - await advanceTimeBy(3, 'minutes'); - // Not part of product spec - t.like(readLatest(metricsPath), { - numActiveVaults: 0, - numLiquidationsCompleted: setup.vaults.length, - numLiquidatingVaults: 0, - retainedCollateral: { value: 0n }, - totalCollateral: { value: 0n }, - totalCollateralSold: { value: 35340699n }, - totalDebt: { value: 0n }, - totalOverageReceived: { value: 0n }, - totalProceedsReceived: { value: 309540000n }, - totalShortfallReceived: { value: 0n }, - }); - - console.log(collateralBrandKey, 'step 10 of 10'); - // continuing after now would start a new auction - { - const { nextDescendingStepTime, nextStartTime } = readLatest( - 'published.auction.schedule', - ) as Record; - t.is(nextDescendingStepTime.absValue, nextStartTime.absValue); - } - - // bid3 still live because it's not fully satisfied - const { liveOffers } = readLatest('published.wallet.agoric1buyer.current'); - t.is(liveOffers[0][1].id, `${collateralBrandKey}-bid3`); - - // restart Zoe - // /////// Upgrading //////////////////////////////// - await buildAndExecuteProposal( - '@agoric/builders/scripts/vats/null-upgrade-zoe-proposal.js', - ); - - await buyer.tryExitOffer(`${collateralBrandKey}-bid3`); - t.like(readLatest('published.wallet.agoric1buyer'), { - status: { - id: `${collateralBrandKey}-bid3`, - payouts: likePayouts(outcome.bids[2].payouts), - }, - }); - - // TODO express spec up top in a way it can be passed in here - // check.vaultNotification(managerIndex, 0, { - // debt: undefined, - // vaultState: 'liquidated', - // locked: { - // value: scale6(outcome.vaultsActual[0].locked), - // }, - // }); - // check.vaultNotification(managerIndex, 1, { - // debt: undefined, - // vaultState: 'liquidated', - // locked: { - // value: scale6(outcome.vaultsActual[1].locked), - // }, - // }); - } + t.like(await buyer.getLatestUpdateRecord(), { + currentAmount: { + // brand from EV() doesn't compare correctly + // brand: invitationBrand, + value: [], + }, + updated: 'balance', + }); - // // check reserve balances - // t.like(readLatest('published.reserve.metrics'), { - // allocations: { - // [collateralBrandKey]: { - // value: scale6(outcome.reserve.allocations[collateralBrandKey]), - // }, - // }, - // shortfallBalance: { value: scale6(outcome.reserve.shortfall) }, - // }); -}; + await buyer.executeOfferMaker(Offers.vaults.OpenVault, { + offerId: 'open1', + collateralBrandKey: 'ATOM', + wantMinted: 5.0, + giveCollateral: 9.0, + }); -test.serial.failing( - 'wallet survives zoe null upgrade', - checkFlow1, - { collateralBrandKey: 'ATOM', managerIndex: 0 }, - {}, -); + t.like(buyer.getLatestUpdateRecord(), { + updated: 'offerStatus', + status: { id: 'open1', numWantsSatisfied: 1 }, + }); +}); diff --git a/packages/boot/test/bootstrapTests/walletFactory.ts b/packages/boot/test/bootstrapTests/walletFactory.ts new file mode 100644 index 000000000000..515cb75df3bc --- /dev/null +++ b/packages/boot/test/bootstrapTests/walletFactory.ts @@ -0,0 +1,51 @@ +import { + AgoricNamesRemotes, + makeAgoricNamesRemotesFromFakeStorage, +} from '@agoric/vats/tools/board-utils.js'; +import { makeSwingsetTestKit } from '../../tools/supports.ts'; +import { makeWalletFactoryDriver } from '../../tools/drivers.ts'; + +const { Fail } = assert; + +export const makeWalletFactoryContext = async t => { + const swingsetTestKit = await makeSwingsetTestKit(t.log, 'bundles/vaults', { + configSpecifier: '@agoric/vm-config/decentral-main-vaults-config.json', + }); + + const { runUtils, storage } = swingsetTestKit; + console.timeLog('DefaultTestContext', 'swingsetTestKit'); + const { EV } = runUtils; + + // Wait for ATOM to make it into agoricNames + await EV.vat('bootstrap').consumeItem('vaultFactoryKit'); + console.timeLog('DefaultTestContext', 'vaultFactoryKit'); + + // has to be late enough for agoricNames data to have been published + const agoricNamesRemotes: AgoricNamesRemotes = + makeAgoricNamesRemotesFromFakeStorage(swingsetTestKit.storage); + const refreshAgoricNamesRemotes = () => { + Object.assign( + agoricNamesRemotes, + makeAgoricNamesRemotesFromFakeStorage(swingsetTestKit.storage), + ); + }; + agoricNamesRemotes.brand.ATOM || Fail`ATOM missing from agoricNames`; + console.timeLog('DefaultTestContext', 'agoricNamesRemotes'); + + const walletFactoryDriver = await makeWalletFactoryDriver( + runUtils, + storage, + agoricNamesRemotes, + ); + return { + ...swingsetTestKit, + swingsetTestKit, + agoricNamesRemotes, + refreshAgoricNamesRemotes, + walletFactoryDriver, + }; +}; + +export type WalletFactoryTestContext = Awaited< + ReturnType +>; diff --git a/packages/inter-protocol/src/price/roundsManager.js b/packages/inter-protocol/src/price/roundsManager.js index 0347ab4d48bb..6413238da67f 100644 --- a/packages/inter-protocol/src/price/roundsManager.js +++ b/packages/inter-protocol/src/price/roundsManager.js @@ -432,8 +432,10 @@ export const prepareRoundsManagerKit = baggage => ); } - if (status.lastReportedRound >= roundId) + if (status.lastReportedRound >= roundId) { return 'cannot report on previous rounds'; + } + if ( roundId !== reportingRoundId && roundId !== add(reportingRoundId, 1) && diff --git a/packages/inter-protocol/test/smartWallet/test-oracle-integration.js b/packages/inter-protocol/test/smartWallet/test-oracle-integration.js index a11846e0c867..36175142c58a 100644 --- a/packages/inter-protocol/test/smartWallet/test-oracle-integration.js +++ b/packages/inter-protocol/test/smartWallet/test-oracle-integration.js @@ -154,7 +154,7 @@ const acceptInvitation = async (wallet, priceAggregator) => { let pushPriceCounter = 0; /** - * @param {any} wallet + * @param {import('@agoric/smart-wallet/src/smartWallet.js').SmartWallet} wallet * @param {string} adminOfferId * @param {import('@agoric/inter-protocol/src/price/roundsManager.js').PriceRound} priceRound * @returns {Promise} offer id @@ -329,6 +329,7 @@ test.serial('errors', async t => { 'In "pushPrice" method of (OracleKit oracle): arg 0: unitPrice: number 1 - Must be a bigint', }, ); + await eventLoopIteration(); // Success, round starts diff --git a/packages/inter-protocol/test/smartWallet/test-psm-integration.js b/packages/inter-protocol/test/smartWallet/test-psm-integration.js index f3cc6448c4c3..0810f3731bd1 100644 --- a/packages/inter-protocol/test/smartWallet/test-psm-integration.js +++ b/packages/inter-protocol/test/smartWallet/test-psm-integration.js @@ -193,11 +193,6 @@ test('want stable (insufficient funds)', async t => { 'Withdrawal of {"brand":"[Alleged: AUSD brand]","value":"[20000n]"} failed because the purse only contained {"brand":"[Alleged: AUSD brand]","value":"[10000n]"}'; const status = computedState.offerStatuses.get('insufficientFunds'); t.is(status?.error, `Error: ${msg}`); - /** @type {[PromiseRejectedResult]} */ - // @ts-expect-error cast - const result = status.result; - t.is(result[0].status, 'rejected'); - t.is(result[0].reason.message, msg); }); test('govern offerFilter', async t => { @@ -384,6 +379,8 @@ test('deposit multiple payments to unknown brand', async t => { } }); +// related to recovering dropped Payments + // XXX belongs in smart-wallet package, but needs lots of set-up that's handy here. test('recover when some withdrawals succeed and others fail', async t => { const { fromEntries } = Object; diff --git a/packages/smart-wallet/package.json b/packages/smart-wallet/package.json index daf497575d3f..e88a2eb00fb2 100644 --- a/packages/smart-wallet/package.json +++ b/packages/smart-wallet/package.json @@ -27,6 +27,7 @@ "dependencies": { "@agoric/assert": "^0.6.0", "@agoric/casting": "^0.4.2", + "@agoric/deploy-script-support": "^0.10.3", "@agoric/ertp": "^0.16.2", "@agoric/internal": "^0.3.2", "@agoric/notifier": "^0.6.2", diff --git a/packages/smart-wallet/src/offerWatcher.js b/packages/smart-wallet/src/offerWatcher.js new file mode 100644 index 000000000000..85ea20619527 --- /dev/null +++ b/packages/smart-wallet/src/offerWatcher.js @@ -0,0 +1,243 @@ +import { E, passStyleOf } from '@endo/far'; + +import { isUpgradeDisconnection } from '@agoric/internal/src/upgrade-api.js'; +import { prepareExoClassKit, watchPromise } from '@agoric/vat-data'; +import { M } from '@agoric/store'; +import { + PaymentPKeywordRecordShape, + SeatShape, +} from '@agoric/zoe/src/typeGuards.js'; +import { AmountShape } from '@agoric/ertp/src/typeGuards.js'; +import { deeplyFulfilledObject, objectMap } from '@agoric/internal'; + +import { UNPUBLISHED_RESULT } from './offers.js'; + +/** + * @typedef {import('./offers.js').OfferSpec & { + * error?: string, + * numWantsSatisfied?: number + * result?: unknown | typeof import('./offers.js').UNPUBLISHED_RESULT, + * payouts?: AmountKeywordRecord, + * }} OfferStatus + */ + +/** + * @template {any} T + * @typedef {import('@agoric/swingset-liveslots').PromiseWatcher} OfferPromiseWatcher, + * numWantsWatcher: OfferPromiseWatcher, + * paymentWatcher: OfferPromiseWatcher, + * }} OutcomeWatchers + */ + +/** + * @param {OutcomeWatchers} watchers + * @param {UserSeat} seat + */ +const watchForOfferResult = ({ resultWatcher }, seat) => { + const p = E(seat).getOfferResult(); + watchPromise(p, resultWatcher, seat); + return p; +}; + +/** + * @param {OutcomeWatchers} watchers + * @param {UserSeat} seat + */ +const watchForNumWants = ({ numWantsWatcher }, seat) => { + const p = E(seat).numWantsSatisfied(); + watchPromise(p, numWantsWatcher, seat); + return p; +}; + +/** + * @param {OutcomeWatchers} watchers + * @param {UserSeat} seat + */ +const watchForPayout = ({ paymentWatcher }, seat) => { + const p = E(seat).getPayouts(); + watchPromise(p, paymentWatcher, seat); + return p; +}; + +/** + * @param {OutcomeWatchers} watchers + * @param {UserSeat} seat + */ +export const watchOfferOutcomes = (watchers, seat) => { + return Promise.all([ + watchForOfferResult(watchers, seat), + watchForNumWants(watchers, seat), + watchForPayout(watchers, seat), + ]); +}; + +const offerWatcherGuard = harden({ + helper: M.interface('InstanceAdminStorage', { + updateStatus: M.call(M.any()).returns(), + onNewContinuingOffer: M.call( + M.or(M.number(), M.string()), + AmountShape, + M.any(), + ) + .optional(M.record()) + .returns(), + publishResult: M.call(M.any()).returns(), + }), + paymentWatcher: M.interface('paymentWatcher', { + onFulfilled: M.call(PaymentPKeywordRecordShape, SeatShape).returns( + M.promise(), + ), + onRejected: M.call(M.any(), SeatShape).returns(), + }), + resultWatcher: M.interface('resultWatcher', { + onFulfilled: M.call(M.any(), SeatShape).returns(), + onRejected: M.call(M.any(), SeatShape).returns(), + }), + numWantsWatcher: M.interface('numWantsWatcher', { + onFulfilled: M.call(M.number(), SeatShape).returns(), + onRejected: M.call(M.any(), SeatShape).returns(), + }), +}); + +export const prepareOfferWatcher = baggage => { + return prepareExoClassKit( + baggage, + 'OfferWatcher', + offerWatcherGuard, + (walletHelper, deposit, offerSpec, address, iAmount, seatRef) => ({ + walletHelper, + deposit, + status: offerSpec, + address, + invitationAmount: iAmount, + seatRef, + }), + { + helper: { + updateStatus(offerStatusUpdates) { + const { state } = this; + state.status = harden({ ...state.status, ...offerStatusUpdates }); + + state.walletHelper.updateStatus(state.status); + }, + onNewContinuingOffer( + offerId, + invitationAmount, + invitationMakers, + publicSubscribers, + ) { + const { state } = this; + + void state.walletHelper.addContinuingOffer( + offerId, + invitationAmount, + invitationMakers, + publicSubscribers, + ); + }, + + publishResult(result) { + const { state, facets } = this; + + const passStyle = passStyleOf(result); + // someday can we get TS to type narrow based on the passStyleOf result match? + switch (passStyle) { + case 'bigint': + case 'boolean': + case 'null': + case 'number': + case 'string': + case 'symbol': + case 'undefined': + facets.helper.updateStatus({ result }); + break; + case 'copyRecord': + if ('invitationMakers' in result) { + // save for continuing invitation offer + + void facets.helper.onNewContinuingOffer( + String(state.status.id), + state.invitationAmount, + result.invitationMakers, + result.publicSubscribers, + ); + } + facets.helper.updateStatus({ result: UNPUBLISHED_RESULT }); + break; + default: + // drop the result + facets.helper.updateStatus({ result: UNPUBLISHED_RESULT }); + } + }, + }, + + /** @type {OutcomeWatchers['paymentWatcher']} */ + paymentWatcher: { + async onFulfilled(payouts) { + const { state, facets } = this; + + // This will block until all payouts succeed, but user will be updated + // since each payout will trigger its corresponding purse notifier. + const amountPKeywordRecord = objectMap(payouts, paymentRef => + E.when(paymentRef, payment => state.deposit.receive(payment)), + ); + const amounts = await deeplyFulfilledObject(amountPKeywordRecord); + facets.helper.updateStatus({ payouts: amounts }); + }, + /** + * @param {Error} err + * @param {UserSeat} seat + */ + onRejected(err, seat) { + const { facets } = this; + if (isUpgradeDisconnection(err)) { + void watchForPayout(facets, seat); + } + }, + }, + + /** @type {OutcomeWatchers['resultWatcher']} */ + resultWatcher: { + onFulfilled(result) { + const { facets } = this; + facets.helper.publishResult(result); + }, + /** + * @param {Error} err + * @param {UserSeat} seat + */ + onRejected(err, seat) { + const { facets } = this; + if (isUpgradeDisconnection(err)) { + void watchForOfferResult(facets, seat); + } + }, + }, + + /** @type {OutcomeWatchers['numWantsWatcher']} */ + numWantsWatcher: { + onFulfilled(numSatisfied) { + const { facets } = this; + + facets.helper.updateStatus({ numWantsSatisfied: numSatisfied }); + }, + /** + * @param {Error} err + * @param {UserSeat} seat + */ + onRejected(err, seat) { + const { facets } = this; + void watchForNumWants(facets, seat); + }, + }, + }, + ); +}; +harden(prepareOfferWatcher); + +/** @typedef {ReturnType} MakeOfferWatcher */ diff --git a/packages/smart-wallet/src/offers.js b/packages/smart-wallet/src/offers.js index 10b4848fa689..a6f799200b17 100644 --- a/packages/smart-wallet/src/offers.js +++ b/packages/smart-wallet/src/offers.js @@ -1,7 +1,3 @@ -import { E, passStyleOf } from '@endo/far'; -import { deeplyFulfilledObject } from '@agoric/internal'; -import { makePaymentsHelper } from './payments.js'; - /** * @typedef {number | string} OfferId */ @@ -26,171 +22,3 @@ export const UNPUBLISHED_RESULT = 'UNPUBLISHED'; * payouts?: AmountKeywordRecord, * }} OfferStatus */ - -/* eslint-disable jsdoc/check-param-names -- bug(?) with nested objects */ -/** - * @param {object} opts - * @param {ERef} opts.zoe - * @param {{ receive: (payment: *) => Promise }} opts.depositFacet - * @param {ERef>} opts.invitationIssuer - * @param {object} opts.powers - * @param {Pick} opts.powers.logger - * @param {(spec: import('./invitations').InvitationSpec) => ERef} opts.powers.invitationFromSpec - * @param {(brand: Brand) => Promise} opts.powers.purseForBrand - * @param {(status: OfferStatus) => void} opts.onStatusChange - * @param {(offerId: string, invitationAmount: Amount<'set'>, invitationMakers: import('./types').InvitationMakers, publicSubscribers: import('./types').PublicSubscribers | import('@agoric/zoe/src/contractSupport').TopicsRecord ) => Promise} opts.onNewContinuingOffer - */ -export const makeOfferExecutor = ({ - zoe, - depositFacet, - invitationIssuer, - powers, - onStatusChange, - onNewContinuingOffer, -}) => { - const { invitationFromSpec, logger, purseForBrand } = powers; - - return { - /** - * Take an offer description provided in capData, augment it with payments and call zoe.offer() - * - * @param {OfferSpec} offerSpec - * @param {(seatRef: UserSeat) => void} onSeatCreated - * @returns {Promise} when the offer has been sent to Zoe; payouts go into this wallet's purses - * @throws if any parts of the offer are determined to be invalid before calling Zoe's `offer()` - */ - async executeOffer(offerSpec, onSeatCreated) { - logger.info('starting executeOffer', offerSpec.id); - - const paymentsManager = makePaymentsHelper(purseForBrand, depositFacet); - - /** @type {OfferStatus} */ - let status = { - ...offerSpec, - }; - /** @param {Partial} changes */ - const updateStatus = changes => { - status = { ...status, ...changes }; - onStatusChange(status); - }; - - /** @type {UserSeat} */ - let seatRef; - - const tryBody = async () => { - // 1. Prepare values and validate synchronously. - const { id, invitationSpec, proposal, offerArgs } = offerSpec; - - /** @type {PaymentKeywordRecord | undefined} */ - const paymentKeywordRecord = await (proposal?.give && - deeplyFulfilledObject(paymentsManager.withdrawGive(proposal.give))); - - const invitation = invitationFromSpec(invitationSpec); - const invitationAmount = - await E(invitationIssuer).getAmountOf(invitation); - - // 2. Begin executing offer - // No explicit signal to user that we reached here but if anything above - // failed they'd get an 'error' status update. - - seatRef = await E(zoe).offer( - invitation, - proposal, - paymentKeywordRecord, - offerArgs, - ); - logger.info(id, 'seated'); - onSeatCreated(seatRef); - - const publishResult = E.when(E(seatRef).getOfferResult(), result => { - const passStyle = passStyleOf(result); - logger.info(id, 'offerResult', passStyle, result); - // someday can we get TS to type narrow based on the passStyleOf result match? - switch (passStyle) { - case 'bigint': - case 'boolean': - case 'null': - case 'number': - case 'string': - case 'symbol': - case 'undefined': - updateStatus({ result }); - break; - case 'copyRecord': - // @ts-expect-error result narrowed by passStyle - if ('invitationMakers' in result) { - // save for continuing invitation offer - void onNewContinuingOffer( - String(id), - invitationAmount, - // @ts-expect-error result narrowed by passStyle - result.invitationMakers, - // @ts-expect-error result narrowed by passStyle - result.publicSubscribers, - ); - } - // copyRecord is valid to publish but not safe as it may have private info - updateStatus({ result: UNPUBLISHED_RESULT }); - break; - default: - // drop the result - updateStatus({ result: UNPUBLISHED_RESULT }); - } - }); - - const publishWantsSatisfied = E.when( - E(seatRef).numWantsSatisfied(), - numSatisfied => { - logger.info(id, 'numSatisfied', numSatisfied); - if (numSatisfied === 0) { - updateStatus({ numWantsSatisfied: 0 }); - } - updateStatus({ - numWantsSatisfied: numSatisfied, - }); - }, - ); - - // This will block until all payouts succeed, but user will be updated - // as each payout will trigger its corresponding purse notifier. - const publishPayouts = E.when(E(seatRef).getPayouts(), payouts => - paymentsManager.depositPayouts(payouts).then(amountsOrDeferred => { - updateStatus({ payouts: amountsOrDeferred }); - }), - ); - - // The offer is complete when these promises are resolved. - // If any reject then executeOffer rejects and that must be handled. - return Promise.all([ - publishResult, - publishWantsSatisfied, - publishPayouts, - ]); - }; - - await tryBody().catch(err => { - logger.error('OFFER ERROR:', err); - // Notify the user - updateStatus({ error: err.toString() }); - // Attempt to recover payments - void paymentsManager.tryReclaimingWithdrawnPayments().then(result => { - if (result) { - updateStatus({ result }); - } - }); - if (seatRef) { - void E(seatRef) - .hasExited() - .then(hasExited => { - if (!hasExited) { - void E(seatRef).tryExit(); - } - }); - } - // propagate to caller - throw err; - }); - }, - }; -}; -harden(makeOfferExecutor); diff --git a/packages/smart-wallet/src/payments.js b/packages/smart-wallet/src/payments.js deleted file mode 100644 index cb79a3af724c..000000000000 --- a/packages/smart-wallet/src/payments.js +++ /dev/null @@ -1,89 +0,0 @@ -import { Fail } from '@agoric/assert'; -import { deeplyFulfilledObject, objectMap } from '@agoric/internal'; -import { E } from '@endo/far'; - -/** - * Used in an offer execution to manage payments state safely. - * - * @param {(brand: Brand) => Promise} purseForBrand - * @param {{ receive: (payment: *) => Promise }} depositFacet - */ -export const makePaymentsHelper = (purseForBrand, depositFacet) => { - /** @type {PaymentPKeywordRecord | null} */ - let keywordPaymentPromises = null; - - /** - * Tracks from whence our payment came. - * - * @type {Map} - */ - const paymentToPurse = new Map(); - - return { - /** - * @param {AmountKeywordRecord} give - * @returns {PaymentPKeywordRecord} - */ - withdrawGive(give) { - !keywordPaymentPromises || - Fail`withdrawPayments can be called once per helper`; - keywordPaymentPromises = objectMap(give, amount => { - /** @type {Promise} */ - const purseP = purseForBrand(amount.brand); - return Promise.all([purseP, E(purseP).withdraw(amount)]).then( - ([purse, payment]) => { - paymentToPurse.set(payment, purse); - return payment; - }, - ); - }); - return keywordPaymentPromises; - }, - - /** - * Try reclaiming any of our payments that we successfully withdrew, but - * were left unclaimed. - */ - tryReclaimingWithdrawnPayments() { - if (!keywordPaymentPromises) return Promise.resolve(undefined); - const paymentPromises = Object.values(keywordPaymentPromises); - // Use allSettled to ensure we attempt all the deposits, regardless of - // individual rejections. - return Promise.allSettled( - paymentPromises.map(async paymentP => { - // Wait for the withdrawal to complete. This protects against a race - // when updating paymentToPurse. - const payment = await paymentP; - - // Find out where it came from. - const purse = paymentToPurse.get(payment); - if (purse === undefined) { - // We already tried to reclaim this payment, so stop here. - return undefined; - } - - // Now send it back to the purse. - try { - return E(purse).deposit(payment); - } finally { - // Once we've called addPayment, mark this one as done. - paymentToPurse.delete(payment); - } - }), - ); - }, - - /** - * @param {PaymentPKeywordRecord} payouts - * @returns {Promise} amounts for deferred deposits will be empty - */ - async depositPayouts(payouts) { - /** Record> */ - const amountPKeywordRecord = objectMap(payouts, paymentRef => - E.when(paymentRef, payment => depositFacet.receive(payment)), - ); - return deeplyFulfilledObject(amountPKeywordRecord); - }, - }; -}; -harden(makePaymentsHelper); diff --git a/packages/smart-wallet/src/proposals/upgrade-wallet-factory2-proposal.js b/packages/smart-wallet/src/proposals/upgrade-wallet-factory2-proposal.js new file mode 100644 index 000000000000..60252d54616c --- /dev/null +++ b/packages/smart-wallet/src/proposals/upgrade-wallet-factory2-proposal.js @@ -0,0 +1,58 @@ +// @ts-check +import { E } from '@endo/far'; +import { makeStorageNodeChild } from '@agoric/internal/src/lib-chainStorage.js'; + +/** + * @param {BootstrapPowers & ChainBootstrapSpace} powers + * @param {object} options + * @param {{ walletRef: VatSourceRef }} options.options + */ +export const upgradeWalletFactory = async ( + { + consume: { + walletFactoryStartResult, + provisionPoolStartResult, + chainStorage, + walletBridgeManager: walletBridgeManagerP, + }, + }, + options, +) => { + const WALLET_STORAGE_PATH_SEGMENT = 'wallet'; + + const { walletRef } = options.options; + + const [walletBridgeManager, walletStorageNode, ppFacets] = await Promise.all([ + walletBridgeManagerP, + makeStorageNodeChild(chainStorage, WALLET_STORAGE_PATH_SEGMENT), + provisionPoolStartResult, + ]); + const walletReviver = await E(ppFacets.creatorFacet).getWalletReviver(); + + const privateArgs = { + storageNode: walletStorageNode, + walletBridgeManager, + walletReviver, + }; + + const { adminFacet } = await walletFactoryStartResult; + + assert(walletRef.bundleID); + await E(adminFacet).upgradeContract(walletRef.bundleID, privateArgs); + + console.log(`Successfully upgraded WalletFactory`); +}; + +export const getManifestForUpgradeWallet = (_powers, { walletRef }) => ({ + manifest: { + [upgradeWalletFactory.name]: { + consume: { + walletFactoryStartResult: 'walletFactoryStartResult', + provisionPoolStartResult: 'provisionPoolStartResult', + chainStorage: 'chainStorage', + walletBridgeManager: 'walletBridgeManager', + }, + }, + }, + options: { walletRef }, +}); diff --git a/packages/smart-wallet/src/proposals/upgrade-wallet-factory2.js b/packages/smart-wallet/src/proposals/upgrade-wallet-factory2.js new file mode 100644 index 000000000000..a58f856ca442 --- /dev/null +++ b/packages/smart-wallet/src/proposals/upgrade-wallet-factory2.js @@ -0,0 +1,29 @@ +import { makeHelpers } from '@agoric/deploy-script-support'; + +/** + * @file + * `agoric run scripts/vats/upgrade-wallet-factory2.js | tee run-report.txt` + * produces a proposal and permit file, as well as the necessary bundles. It + * also prints helpful instructions for copying the files and installing them. + */ + +/** @type {import('@agoric/deploy-script-support/src/externalTypes.js').ProposalBuilder} */ +export const defaultProposalBuilder = async ({ publishRef, install }) => + harden({ + sourceSpec: + '@agoric/smart-wallet/src/proposals/upgrade-wallet-factory2-proposal.js', + getManifestCall: [ + 'getManifestForUpgradeWallet', + { + walletRef: publishRef( + // @ts-expect-error eslint is confused. The call is correct. + install('@agoric/smart-wallet/src/walletFactory.js'), + ), + }, + ], + }); + +export default async (homeP, endowments) => { + const { writeCoreProposal } = await makeHelpers(homeP, endowments); + await writeCoreProposal('upgrade-wallet-factory', defaultProposalBuilder); +}; diff --git a/packages/smart-wallet/src/smartWallet.js b/packages/smart-wallet/src/smartWallet.js index d7a1f94a1454..98b6ac415daf 100644 --- a/packages/smart-wallet/src/smartWallet.js +++ b/packages/smart-wallet/src/smartWallet.js @@ -1,3 +1,4 @@ +import { E } from '@endo/far'; import { AmountShape, BrandShape, @@ -6,7 +7,12 @@ import { PaymentShape, PurseShape, } from '@agoric/ertp'; -import { StorageNodeShape, makeTracer } from '@agoric/internal'; +import { + deeplyFulfilledObject, + makeTracer, + objectMap, + StorageNodeShape, +} from '@agoric/internal'; import { observeNotifier } from '@agoric/notifier'; import { M, mustMatch } from '@agoric/store'; import { @@ -20,15 +26,19 @@ import { provide, } from '@agoric/vat-data'; import { + prepareRecorderKit, SubscriberShape, TopicsRecordShape, - prepareRecorderKit, } from '@agoric/zoe/src/contractSupport/index.js'; -import { E } from '@endo/far'; +import { + AmountKeywordRecordShape, + PaymentPKeywordRecordShape, +} from '@agoric/zoe/src/typeGuards.js'; + import { makeInvitationsHelper } from './invitations.js'; -import { makeOfferExecutor } from './offers.js'; import { shape } from './typeGuards.js'; import { objectMapStoragePath } from './utils.js'; +import { prepareOfferWatcher, watchOfferOutcomes } from './offerWatcher.js'; const { Fail, quote: q } = assert; @@ -40,17 +50,36 @@ const trace = makeTracer('SmrtWlt'); * @see {@link ../README.md}} */ +/** @typedef {number | string} OfferId */ + +/** + * @typedef {{ + * id: OfferId, + * invitationSpec: import('./invitations').InvitationSpec, + * proposal: Proposal, + * offerArgs?: unknown + * }} OfferSpec + */ + +/** + * @typedef {{ + * logger: {info: (...args: any[]) => void, error: (...args: any[]) => void}, + * makeOfferWatcher: import('./offerWatcher.js').MakeOfferWatcher, + * invitationFromSpec: ERef, + * }} ExecutorPowers + */ + /** * @typedef {{ * method: 'executeOffer' - * offer: import('./offers.js').OfferSpec, + * offer: OfferSpec, * }} ExecuteOfferAction */ /** * @typedef {{ * method: 'tryExitOffer' - * offerId: import('./offers.js').OfferId, + * offerId: OfferId, * }} TryExitOfferAction */ @@ -81,7 +110,7 @@ const trace = makeTracer('SmrtWlt'); * purses: Array<{brand: Brand, balance: Amount}>, * offerToUsedInvitation: Array<[ offerId: string, usedInvitation: Amount ]>, * offerToPublicSubscriberPaths: Array<[ offerId: string, publicTopics: { [subscriberName: string]: string } ]>, - * liveOffers: Array<[import('./offers.js').OfferId, import('./offers.js').OfferStatus]>, + * liveOffers: Array<[OfferId, import('./offers.js').OfferStatus]>, * }} CurrentWalletRecord */ @@ -145,8 +174,9 @@ const trace = makeTracer('SmrtWlt'); * purseBalances: MapStore, * updateRecorderKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit, * currentRecorderKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit, - * liveOffers: MapStore, - * liveOfferSeats: WeakMapStore>, + * liveOffers: MapStore, + * liveOfferSeats: MapStore>, + * liveOfferPayments: MapStore>, * }>} ImmutableState * * @typedef {BrandDescriptor & { purse: Purse }} PurseRecord @@ -203,6 +233,8 @@ const getBrandToPurses = (walletPurses, key) => { return brandToPurses; }; +const REPAIRED_UNWATCHED_SEATS = 'repairedUnwatchedSeats'; + /** * @param {import('@agoric/vat-data').Baggage} baggage * @param {SharedParams} shared @@ -232,8 +264,9 @@ export const prepareSmartWallet = (baggage, shared) => { return store; }); + const makeOfferWatcher = prepareOfferWatcher(baggage); + /** - * * @param {UniqueParams} unique * @returns {State} */ @@ -297,6 +330,9 @@ export const prepareSmartWallet = (baggage, shared) => { liveOfferSeats: makeScalarBigMapStore('live offer seats', { durable: true, }), + liveOfferPayments: makeScalarBigMapStore('live offer payments', { + durable: true, + }), }; return { @@ -315,10 +351,28 @@ export const prepareSmartWallet = (baggage, shared) => { .returns(M.promise()), publishCurrentState: M.call().returns(), watchPurse: M.call(M.eref(PurseShape)).returns(M.promise()), + repairUnwatchedSeats: M.call().returns(), + updateStatus: M.call(M.any()).returns(), + addContinuingOffer: M.call( + M.or(M.number(), M.string()), + AmountShape, + M.remotable('InvitationMaker'), + M.or(M.record(), M.undefined()), + ).returns(M.promise()), + purseForBrand: M.call(BrandShape).returns(M.promise()), + logWalletInfo: M.call(M.any()).returns(), + logWalletError: M.call(M.any()).returns(), }), + deposit: M.interface('depositFacetI', { receive: M.callWhen(M.await(M.eref(PaymentShape))).returns(AmountShape), }), + payments: M.interface('payments support', { + withdrawGive: M.call(AmountKeywordRecordShape).returns( + PaymentPKeywordRecordShape, + ), + tryReclaimingWithdrawnPayments: M.call(M.string()).returns(M.promise()), + }), offers: M.interface('offers facet', { executeOffer: M.call(shape.OfferSpec).returns(M.promise()), tryExitOffer: M.call(M.scalar()).returns(M.promise()), @@ -358,6 +412,7 @@ export const prepareSmartWallet = (baggage, shared) => { const { liveOffers, liveOfferSeats, + liveOfferPayments, offerToInvitationMakers, offerToPublicSubscriberPaths, offerToUsedInvitation, @@ -365,6 +420,7 @@ export const prepareSmartWallet = (baggage, shared) => { const used = liveOffers.has(id) || liveOfferSeats.has(id) || + liveOfferPayments.has(id) || offerToInvitationMakers.has(id) || offerToPublicSubscriberPaths.has(id) || offerToUsedInvitation.has(id); @@ -412,7 +468,7 @@ export const prepareSmartWallet = (baggage, shared) => { /** @type {(purse: ERef) => Promise} */ async watchPurse(purseRef) { - const { address } = this.state; + const { facets } = this; const purse = await purseRef; // promises don't fit in durable storage @@ -422,8 +478,7 @@ export const prepareSmartWallet = (baggage, shared) => { E(purse).getCurrentAmount(), balance => helper.updateBalance(purse, balance), err => - console.error( - address, + facets.helper.logWalletError( 'initial purse balance publish failed', err, ), @@ -433,7 +488,10 @@ export const prepareSmartWallet = (baggage, shared) => { helper.updateBalance(purse, balance); }, fail(reason) { - console.error(address, `failed updateState observer`, reason); + facets.helper.logWalletError( + '⚠️ failed updateState observer', + reason, + ); }, }); }, @@ -442,7 +500,7 @@ export const prepareSmartWallet = (baggage, shared) => { * Provide a purse given a NameHub of issuers and their * brands. * - * We current support only one NameHub, agoricNames, and + * We currently support only one NameHub, agoricNames, and * hence one purse per brand. But we store an array of them * to facilitate a transition to decentralized introductions. * @@ -494,6 +552,121 @@ export const prepareSmartWallet = (baggage, shared) => { void helper.watchPurse(purse); return purse; }, + + repairUnwatchedSeats() { + const { state, facets } = this; + const { address, invitationPurse } = state; + const { liveOffers, liveOfferSeats } = state; + const { zoe, agoricNames } = shared; + const { invitationBrand, invitationIssuer } = shared; + + if (baggage.has(REPAIRED_UNWATCHED_SEATS)) { + return; + } + baggage.init(REPAIRED_UNWATCHED_SEATS, 'present`'); + + const invitationFromSpec = makeInvitationsHelper( + zoe, + agoricNames, + invitationBrand, + invitationPurse, + state.offerToInvitationMakers.get, + ); + + for (const seatId of liveOfferSeats.keys()) { + const offerSpec = liveOffers.get(seatId); + const seat = liveOfferSeats.get(seatId); + + const invitation = invitationFromSpec(offerSpec.invitationSpec); + const watcher = makeOfferWatcher( + facets.helper, + offerSpec, + address, + E(invitationIssuer).getAmountOf(invitation), + seat, + ); + + void watchOfferOutcomes(watcher, seat); + } + }, + + /** @param {import('./offers.js').OfferStatus} offerStatus */ + updateStatus(offerStatus) { + const { state, facets } = this; + facets.helper.logWalletInfo('offerStatus', offerStatus); + + void state.updateRecorderKit.recorder.write({ + updated: 'offerStatus', + status: offerStatus, + }); + + if ('numWantsSatisfied' in offerStatus) { + if (state.liveOfferSeats.has(offerStatus.id)) { + state.liveOfferSeats.delete(offerStatus.id); + } + + if (state.liveOfferPayments.has(offerStatus.id)) { + state.liveOfferPayments.delete(offerStatus.id); + } + + if (state.liveOffers.has(offerStatus.id)) { + state.liveOffers.delete(offerStatus.id); + // This might get skipped in subsequent passes, since we .delete() + // the first time through + facets.helper.publishCurrentState(); + } + } + }, + async addContinuingOffer( + offerId, + invitationAmount, + invitationMakers, + publicSubscribers, + ) { + const { state, facets } = this; + + state.offerToUsedInvitation.init(offerId, invitationAmount); + state.offerToInvitationMakers.init(offerId, invitationMakers); + const pathMap = await objectMapStoragePath(publicSubscribers); + if (pathMap) { + facets.helper.logWalletInfo('recording pathMap', pathMap); + state.offerToPublicSubscriberPaths.init(offerId, pathMap); + } + facets.helper.publishCurrentState(); + }, + + /** + * @param {Brand} brand + * @returns {Promise} + */ + async purseForBrand(brand) { + const { state, facets } = this; + const { registry, invitationBrand } = shared; + + if (registry.has(brand)) { + // @ts-expect-error virtual purse + return E(state.bank).getPurse(brand); + } else if (invitationBrand === brand) { + return state.invitationPurse; + } + + const purse = await facets.helper.getPurseIfKnownBrand( + brand, + shared.agoricNames, + ); + if (purse) { + return purse; + } + throw Fail`cannot find/make purse for ${brand}`; + }, + logWalletInfo(...args) { + const { state } = this; + console.info('wallet', state.address, ...args); + }, + logWalletError(...args) { + const { state } = this; + console.error('wallet', state.address, ...args); + }, }, /** * Similar to {DepositFacet} but async because it has to look up the purse. @@ -509,9 +682,13 @@ export const prepareSmartWallet = (baggage, shared) => { * @throws if there's not yet a purse, though the payment is held to try again when there is */ async receive(payment) { - const { helper } = this.facets; - const { paymentQueues: queues, bank, invitationPurse } = this.state; + const { + state, + facets: { helper }, + } = this; + const { paymentQueues: queues, bank, invitationPurse } = state; const { registry, invitationBrand } = shared; + const brand = await E(payment).getAllegedBrand(); // When there is a purse deposit into it @@ -537,118 +714,181 @@ export const prepareSmartWallet = (baggage, shared) => { throw Fail`cannot deposit payment with brand ${brand}: no purse`; }, }, + + payments: { + /** + * @param {AmountKeywordRecord} give + * @param {OfferId} offerId + * @returns {PaymentPKeywordRecord} + */ + withdrawGive(give, offerId) { + const { state, facets } = this; + + /** @type {MapStore} */ + const brandPaymentRecord = makeScalarBigMapStore('paymentToBrand', { + durable: true, + }); + state.liveOfferPayments.init(offerId, brandPaymentRecord); + + // Add each payment to liveOfferPayments as it is withdrawn. If + // there's an error partway through, we can recover the withdrawals. + return objectMap(give, amount => { + /** @type {Promise} */ + const purseP = facets.helper.purseForBrand(amount.brand); + const paymentP = E(purseP).withdraw(amount); + void E.when( + paymentP, + payment => brandPaymentRecord.init(amount.brand, payment), + e => { + // recovery will be handled by tryReclaimingWithdrawnPayments() + facets.helper.logWalletInfo( + `⚠️ Payment withdrawal failed.`, + offerId, + e, + ); + }, + ); + return paymentP; + }); + }, + + async tryReclaimingWithdrawnPayments(offerId) { + const { state, facets } = this; + const { liveOfferPayments } = state; + + if (liveOfferPayments.has(offerId)) { + const brandPaymentRecord = liveOfferPayments.get(offerId); + if (!brandPaymentRecord) { + return Promise.resolve(undefined); + } + // Use allSettled to ensure we attempt all the deposits, regardless of + // individual rejections. + return Promise.allSettled( + Array.from(brandPaymentRecord.entries()).map(async ([b, p]) => { + // Wait for the withdrawal to complete. This protects against a + // race when updating paymentToPurse. + const purseP = facets.helper.purseForBrand(b); + + // Now send it back to the purse. + return E(purseP).deposit(p); + }), + ); + } + }, + }, + offers: { /** * Take an offer description provided in capData, augment it with payments and call zoe.offer() * - * @param {import('./offers.js').OfferSpec} offerSpec + * @param {OfferSpec} offerSpec * @returns {Promise} after the offer has been both seated and exited by Zoe. * @throws if any parts of the offer can be determined synchronously to be invalid */ async executeOffer(offerSpec) { const { facets, state } = this; - const { - address, - bank, - invitationPurse, - offerToInvitationMakers, - offerToUsedInvitation, - offerToPublicSubscriberPaths, - updateRecorderKit, - } = this.state; - const { invitationBrand, zoe, invitationIssuer, registry } = shared; + const { address, invitationPurse } = state; + const { zoe, agoricNames } = shared; + const { invitationBrand, invitationIssuer } = shared; facets.helper.assertUniqueOfferId(String(offerSpec.id)); - const logger = { - info: (...args) => console.info('wallet', address, ...args), - error: (...args) => console.error('wallet', address, ...args), - }; + await null; + + let seatRef; + let watcher; + try { + const invitationFromSpec = makeInvitationsHelper( + zoe, + agoricNames, + invitationBrand, + invitationPurse, + state.offerToInvitationMakers.get, + ); + + facets.helper.logWalletInfo('starting executeOffer', offerSpec.id); + + // 1. Prepare values and validate synchronously. + const { proposal } = offerSpec; + + const invitation = invitationFromSpec(offerSpec.invitationSpec); + + const [paymentKeywordRecord, invitationAmount] = await Promise.all([ + proposal?.give && + deeplyFulfilledObject( + facets.payments.withdrawGive(proposal.give, offerSpec.id), + ), + E(invitationIssuer).getAmountOf(invitation), + ]); + + // 2. Begin executing offer + // No explicit signal to user that we reached here but if anything above + // failed they'd get an 'error' status update. + + /** @type {UserSeat} */ + seatRef = await E(zoe).offer( + invitation, + proposal, + paymentKeywordRecord, + offerSpec.offerArgs, + ); + facets.helper.logWalletInfo(offerSpec.id, 'seated'); + + watcher = makeOfferWatcher( + facets.helper, + facets.deposit, + offerSpec, + address, + invitationAmount, + seatRef, + ); - const executor = makeOfferExecutor({ - zoe, - depositFacet: facets.deposit, - invitationIssuer, - powers: { - invitationFromSpec: makeInvitationsHelper( - zoe, - shared.agoricNames, - invitationBrand, - invitationPurse, - offerToInvitationMakers.get, - ), - /** - * @param {Brand} brand - * @returns {Promise} - */ - purseForBrand: async brand => { - const { helper } = facets; - if (registry.has(brand)) { - // @ts-expect-error virtual purse - return E(bank).getPurse(brand); - } else if (invitationBrand === brand) { - return invitationPurse; - } + state.liveOffers.init(offerSpec.id, offerSpec); + state.liveOfferSeats.init(offerSpec.id, seatRef); - const purse = await helper.getPurseIfKnownBrand( - brand, - shared.agoricNames, - ); - if (purse) { - return purse; - } - throw Fail`cannot find/make purse for ${brand}`; - }, - logger, - }, - onStatusChange: offerStatus => { - logger.info('offerStatus', offerStatus); + // publish the live offers + facets.helper.publishCurrentState(); - void updateRecorderKit.recorder.write({ - updated: 'offerStatus', - status: offerStatus, + // await so that any errors are caught and handled below + await watchOfferOutcomes(watcher, seatRef); + } catch (err) { + facets.helper.logWalletError('OFFER ERROR:', err); + // Notify the user + if (watcher) { + watcher.helper.updateStatus({ error: err.toString() }); + } else { + facets.helper.updateStatus({ + error: err.toString(), + ...offerSpec, }); + } - const isSeatExited = 'numWantsSatisfied' in offerStatus; - if (isSeatExited) { - if (state.liveOfferSeats.has(offerStatus.id)) { - state.liveOfferSeats.delete(offerStatus.id); - } + if (offerSpec?.proposal?.give) { + facets.payments + .tryReclaimingWithdrawnPayments(offerSpec.id) + .catch(e => + facets.helper.logWalletError( + 'recovery failed reclaiming payments', + e, + ), + ); + } - if (state.liveOffers.has(offerStatus.id)) { - state.liveOffers.delete(offerStatus.id); - facets.helper.publishCurrentState(); + if (seatRef) { + void E.when(E(seatRef).hasExited(), hasExited => { + if (!hasExited) { + void E(seatRef).tryExit(); } - } - }, - /** @type {(offerId: string, invitationAmount: Amount<'set'>, invitationMakers: import('./types').InvitationMakers, publicSubscribers?: import('./types').PublicSubscribers | import('@agoric/zoe/src/contractSupport').TopicsRecord) => Promise} */ - onNewContinuingOffer: async ( - offerId, - invitationAmount, - invitationMakers, - publicSubscribers, - ) => { - offerToUsedInvitation.init(offerId, invitationAmount); - offerToInvitationMakers.init(offerId, invitationMakers); - const pathMap = await objectMapStoragePath(publicSubscribers); - if (pathMap) { - logger.info('recording pathMap', pathMap); - offerToPublicSubscriberPaths.init(offerId, pathMap); - } - facets.helper.publishCurrentState(); - }, - }); + }); + } - return executor.executeOffer(offerSpec, seatRef => { - state.liveOffers.init(offerSpec.id, offerSpec); - facets.helper.publishCurrentState(); - state.liveOfferSeats.init(offerSpec.id, seatRef); - }); + throw err; + } }, /** * Take an offer's id, look up its seat, try to exit. * - * @param {import('./offers.js').OfferId} offerId + * @param {OfferId} offerId * @returns {Promise} * @throws if the seat can't be found or E(seatRef).tryExit() fails. */ @@ -666,14 +906,14 @@ export const prepareSmartWallet = (baggage, shared) => { * @returns {Promise} */ handleBridgeAction(actionCapData, canSpend = false) { + const { facets } = this; + const { offers } = facets; const { publicMarshaller } = shared; - const { offers } = this.facets; - /** @param {Error} err */ const recordError = err => { - const { address, updateRecorderKit } = this.state; - console.error('wallet', address, 'handleBridgeAction error:', err); + const { updateRecorderKit } = this.state; + facets.helper.logWalletError('handleBridgeAction error:', err); void updateRecorderKit.recorder.write({ updated: 'walletAction', status: { error: err.message }, @@ -718,24 +958,27 @@ export const prepareSmartWallet = (baggage, shared) => { }, /** @deprecated use getPublicTopics */ getCurrentSubscriber() { - return this.state.currentRecorderKit.subscriber; + const { state } = this; + return state.currentRecorderKit.subscriber; }, /** @deprecated use getPublicTopics */ getUpdatesSubscriber() { - return this.state.updateRecorderKit.subscriber; + const { state } = this; + return state.updateRecorderKit.subscriber; }, getPublicTopics() { - const { currentRecorderKit, updateRecorderKit } = this.state; + const { state } = this; + return harden({ current: { description: 'Current state of wallet', - subscriber: currentRecorderKit.subscriber, - storagePath: currentRecorderKit.recorder.getStoragePath(), + subscriber: state.currentRecorderKit.subscriber, + storagePath: state.currentRecorderKit.recorder.getStoragePath(), }, updates: { description: 'Changes to wallet', - subscriber: updateRecorderKit.subscriber, - storagePath: updateRecorderKit.recorder.getStoragePath(), + subscriber: state.updateRecorderKit.subscriber, + storagePath: state.updateRecorderKit.recorder.getStoragePath(), }, }); }, @@ -747,6 +990,7 @@ export const prepareSmartWallet = (baggage, shared) => { const { helper } = facets; void helper.watchPurse(invitationPurse); + helper.repairUnwatchedSeats(); }, }, ); diff --git a/packages/smart-wallet/src/utils.js b/packages/smart-wallet/src/utils.js index de4758f5bd94..9ed39bb6841c 100644 --- a/packages/smart-wallet/src/utils.js +++ b/packages/smart-wallet/src/utils.js @@ -9,7 +9,7 @@ const trace = makeTracer('WUTIL', false); /** @param {Brand<'set'>} [invitationBrand] */ export const makeWalletStateCoalescer = (invitationBrand = undefined) => { - /** @type {Map} */ + /** @type {Map} */ const offerStatuses = new Map(); /** @type {Map} */ const balances = new Map(); @@ -17,7 +17,7 @@ export const makeWalletStateCoalescer = (invitationBrand = undefined) => { /** * keyed by description; xxx assumes unique * - * @type {Map} + * @type {Map} */ const invitationsReceived = new Map(); diff --git a/packages/smart-wallet/src/walletFactory.js b/packages/smart-wallet/src/walletFactory.js index bc9cb0c8c48d..e41ab64eba56 100644 --- a/packages/smart-wallet/src/walletFactory.js +++ b/packages/smart-wallet/src/walletFactory.js @@ -129,7 +129,7 @@ export const makeAssetRegistry = assetPublisher => { * }} WalletReviver */ -// NB: even though all the wallets share this contract, they +// NB: even though all the wallets share this contract, // 1. they should not rely on that; they may be partitioned later // 2. they should never be able to detect behaviors from another wallet /** diff --git a/packages/smart-wallet/test/gameAssetContract.js b/packages/smart-wallet/test/gameAssetContract.js index 256727bfd61b..ef982ce9882c 100644 --- a/packages/smart-wallet/test/gameAssetContract.js +++ b/packages/smart-wallet/test/gameAssetContract.js @@ -26,7 +26,7 @@ const totalPlaces = amt => { export const start = async zcf => { const { joinPrice } = zcf.getTerms(); const stableIssuer = await E(zcf.getZoeService()).getFeeIssuer(); - zcf.saveIssuer(stableIssuer, 'Price'); + await zcf.saveIssuer(stableIssuer, 'Price'); const { zcfSeat: gameSeat } = zcf.makeEmptySeatKit(); const mint = await zcf.makeZCFMint('Place', AssetKind.COPY_BAG); diff --git a/packages/smart-wallet/test/test-addAsset.js b/packages/smart-wallet/test/test-addAsset.js index 87f226af64f1..7c423626a154 100644 --- a/packages/smart-wallet/test/test-addAsset.js +++ b/packages/smart-wallet/test/test-addAsset.js @@ -14,7 +14,7 @@ import { makeDefaultTestContext } from './contexts.js'; import { ActionType, headValue, makeMockTestSpace } from './supports.js'; import { makeImportContext } from '../src/marshal-contexts.js'; -const { Fail } = assert; +const { Fail, quote: q } = assert; const importSpec = spec => importMetaResolve(spec, import.meta.url).then(u => new URL(u).pathname); @@ -420,8 +420,10 @@ test.serial('trading in non-vbank asset: game real-estate NFTs', async t => { /** @type {import('../src/smartWallet.js').UpdateRecord} */ const update = await headValue(updates); - assert(update.updated === 'offerStatus'); - // t.log(update.status); + assert( + update.updated === 'offerStatus', + `Should have had "updated":"offerStatus", had "${q(update)}"`, + ); t.like(update, { updated: 'offerStatus', status: { @@ -435,7 +437,7 @@ test.serial('trading in non-vbank asset: game real-estate NFTs', async t => { const { status: { id, result, payouts }, } = update; - // @ts-expect-error cast value to copyBag + // @ts-expect-error status includes payload. const names = payouts?.Places.value.payload.map(([name, _qty]) => name); t.log(id, 'result:', result, ', payouts:', names.join(', ')); @@ -495,13 +497,15 @@ test.serial('non-vbank asset: give before deposit', async t => { proposal: { give, want }, }); t.log('goofy client: propose to give', choices.join(', ')); - await E(walletBridge).proposeOffer(ctx.fromBoard.toCapData(offer1)); + await t.throwsAsync( + () => E(walletBridge).proposeOffer(ctx.fromBoard.toCapData(offer1)), + { message: /Withdrawal of .* failed because the purse only contained/ }, + ); }; { const addr2 = 'agoric1player2'; const walletUIbridge = makePromiseKit(); - // await eventLoopIteration(); const { simpleProvideWallet, consume, sendToBridge } = t.context; const wallet = simpleProvideWallet(addr2); @@ -511,9 +515,8 @@ test.serial('non-vbank asset: give before deposit', async t => { const mockStorage = await consume.chainStorage; const { aPlayer } = makeScenario(t); - aPlayer(addr2, walletUIbridge, mockStorage, sendToBridge, updates); - const c2 = goofyClient(mockStorage, walletUIbridge.promise); - await t.throwsAsync(c2, { message: /Withdrawal of {.*} failed/ }); + await aPlayer(addr2, walletUIbridge, mockStorage, sendToBridge, updates); + await goofyClient(mockStorage, walletUIbridge.promise); await eventLoopIteration(); // wallet balance was also updated