From ec4cbceef219209fdf81bbe09e3f39b804d93e5a Mon Sep 17 00:00:00 2001 From: Turadg Aleahmad Date: Thu, 30 Nov 2023 09:24:21 -0800 Subject: [PATCH] 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 --- .../bootstrapTests/test-vaults-integration.ts | 16 +- .../test-walletSurvivesZoeRestart.ts | 290 ++-------- .../boot/test/bootstrapTests/walletFactory.ts | 51 ++ .../inter-protocol/src/price/roundsManager.js | 4 +- .../src/vaultFactory/vaultManager.js | 2 +- .../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 | 525 ++++++++++++++---- packages/smart-wallet/src/utils.js | 4 +- packages/smart-wallet/src/walletFactory.js | 30 +- .../smart-wallet/test/gameAssetContract.js | 2 +- .../upgradeWalletFactory/walletFactory-V2.js | 8 + packages/smart-wallet/test/test-addAsset.js | 21 +- 19 files changed, 897 insertions(+), 658 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/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 910234eedd9f..6d3151d47210 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 type { TestFn } from 'ava'; + import { BridgeHandler } from '@agoric/vats'; +import { Offers } from '@agoric/inter-protocol/src/clientSupport.js'; import { LiquidationTestContext, - likePayouts, makeLiquidationTestContext, - scale6, LiquidationSetup, } from '../../tools/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 30fed5827d8b..18bc86215f6a 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/src/vaultFactory/vaultManager.js b/packages/inter-protocol/src/vaultFactory/vaultManager.js index 5021e41968c9..0cc969827947 100644 --- a/packages/inter-protocol/src/vaultFactory/vaultManager.js +++ b/packages/inter-protocol/src/vaultFactory/vaultManager.js @@ -1043,7 +1043,7 @@ export const prepareVaultManagerKit = ( state.collateralBrand, ); if (!storedCollateralQuote) - throw Fail`lockOraclePrices called before a collateral quote was available`; + throw Fail`lockOraclePrices called before a collateral quote was available for ${state.collateralBrand}`; trace( `lockOraclePrices`, getAmountIn(storedCollateralQuote), 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..c025e602e2ae 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 */ @@ -129,6 +158,7 @@ const trace = makeTracer('SmrtWlt'); * invitationDisplayInfo: DisplayInfo, * publicMarshaller: Marshaller, * zoe: ERef, + * secretWalletFactoryKey: any, * }} SharedParams * * @typedef {ImmutableState & MutableState} State @@ -145,8 +175,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 @@ -218,6 +249,12 @@ export const prepareSmartWallet = (baggage, shared) => { invitationDisplayInfo: DisplayInfoShape, publicMarshaller: M.remotable('Marshaller'), zoe: M.eref(M.remotable('ZoeService')), + + // known only to smartWallets and walletFactory, this allows the + // walletFactory to invoke functions on the self facet that no one else + // can. Used to protect the upgrade-to-incarnation 2 repair. This can be + // dropped once the repair has taken place. + secretWalletFactoryKey: M.any(), }), ); @@ -232,8 +269,9 @@ export const prepareSmartWallet = (baggage, shared) => { return store; }); + const makeOfferWatcher = prepareOfferWatcher(baggage); + /** - * * @param {UniqueParams} unique * @returns {State} */ @@ -297,6 +335,9 @@ export const prepareSmartWallet = (baggage, shared) => { liveOfferSeats: makeScalarBigMapStore('live offer seats', { durable: true, }), + liveOfferPayments: makeScalarBigMapStore('live offer payments', { + durable: true, + }), }; return { @@ -315,10 +356,30 @@ 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(), + // XXX is there a better guard for a bigMapStore? + getLiveOfferPayments: M.call().returns(M.any()), }), + 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()), @@ -332,6 +393,7 @@ export const prepareSmartWallet = (baggage, shared) => { getCurrentSubscriber: M.call().returns(SubscriberShape), getUpdatesSubscriber: M.call().returns(SubscriberShape), getPublicTopics: M.call().returns(TopicsRecordShape), + repairWalletForIncarnation2: M.call(M.any()).returns(), }), }; @@ -355,6 +417,7 @@ export const prepareSmartWallet = (baggage, shared) => { * @type {(id: string) => void} */ assertUniqueOfferId(id) { + const { facets } = this; const { liveOffers, liveOfferSeats, @@ -365,6 +428,7 @@ export const prepareSmartWallet = (baggage, shared) => { const used = liveOffers.has(id) || liveOfferSeats.has(id) || + facets.helper.getLiveOfferPayments().has(id) || offerToInvitationMakers.has(id) || offerToPublicSubscriberPaths.has(id) || offerToUsedInvitation.has(id); @@ -412,7 +476,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 +486,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 +496,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 +508,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 +560,152 @@ export const prepareSmartWallet = (baggage, shared) => { void helper.watchPurse(purse); return purse; }, + + // see https://github.com/Agoric/agoric-sdk/issues/8445 and + // https://github.com/Agoric/agoric-sdk/issues/8286. As originally + // released, the smartWallet didn't durably monitor the promises for the + // outcomes of offers, and would have dropped them on upgrade of Zoe or + // the smartWallet itself. Using watchedPromises, (see offerWatcher.js) + // we've addressed the problem for new offers. The function will + // backfill the solution for offers that were outstanding before the + // transition to incarnation 2 of the smartWallet. + async repairUnwatchedSeats() { + const { state, facets } = this; + const { address, invitationPurse } = state; + const { liveOffers, liveOfferSeats } = state; + const { zoe, agoricNames } = shared; + const { invitationBrand, invitationIssuer } = shared; + + await null; + + const invitationFromSpec = makeInvitationsHelper( + zoe, + agoricNames, + invitationBrand, + invitationPurse, + state.offerToInvitationMakers.get, + ); + + for (const seatId of liveOfferSeats.keys()) { + facets.helper.logWalletInfo(`repairing ${seatId}`); + const offerSpec = liveOffers.get(seatId); + const seat = liveOfferSeats.get(seatId); + + const invitation = invitationFromSpec(offerSpec.invitationSpec); + const invitationAmount = + await E(invitationIssuer).getAmountOf(invitation); + const watcher = makeOfferWatcher( + facets.helper, + facets.deposit, + offerSpec, + address, + invitationAmount, + seat, + ); + + void watchOfferOutcomes(watcher, seat); + trace(`Repaired seat ${seatId} for wallet ${address}`); + } + }, + + /** @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 (facets.helper.getLiveOfferPayments().has(offerStatus.id)) { + facets.helper.getLiveOfferPayments().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); + }, + // In new SmartWallets, this is part of state, but we can't add fields + // to instance state for older SmartWallets, so put it in baggage. + getLiveOfferPayments() { + const { state } = this; + + if (state.liveOfferPayments) { + return state.liveOfferPayments; + } + + // This will only happen for legacy wallets, before WF incarnation 2 + if (!baggage.has(state.address)) { + trace(`getLiveOfferPayments adding store for ${state.address}`); + baggage.init( + state.address, + makeScalarBigMapStore('live offer payments', { + durable: true, + }), + ); + } + return baggage.get(state.address); + }, }, /** * Similar to {DepositFacet} but async because it has to look up the purse. @@ -509,9 +721,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 +753,183 @@ 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 { facets } = this; + + /** @type {MapStore} */ + const brandPaymentRecord = makeScalarBigMapStore('paymentToBrand', { + durable: true, + }); + facets.helper + .getLiveOfferPayments() + .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 { facets } = this; + + const liveOfferPayments = facets.helper.getLiveOfferPayments(); + 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; - 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; - } + let seatRef; + let watcher; + try { + const invitationFromSpec = makeInvitationsHelper( + zoe, + agoricNames, + invitationBrand, + invitationPurse, + state.offerToInvitationMakers.get, + ); - 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); + facets.helper.logWalletInfo('starting executeOffer', offerSpec.id); - void updateRecorderKit.recorder.write({ - updated: 'offerStatus', - status: offerStatus, - }); + // 1. Prepare values and validate synchronously. + const { proposal } = offerSpec; - const isSeatExited = 'numWantsSatisfied' in offerStatus; - if (isSeatExited) { - if (state.liveOfferSeats.has(offerStatus.id)) { - state.liveOfferSeats.delete(offerStatus.id); - } + const invitation = invitationFromSpec(offerSpec.invitationSpec); - if (state.liveOffers.has(offerStatus.id)) { - state.liveOffers.delete(offerStatus.id); - facets.helper.publishCurrentState(); - } - } - }, - /** @type {(offerId: string, invitationAmount: Amount<'set'>, invitationMakers: import('./types').InvitationMakers, publicSubscribers?: import('./types').PublicSubscribers | import('@agoric/zoe/src/contractSupport').TopicsRecord) => Promise} */ - onNewContinuingOffer: async ( - offerId, + 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, - 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(); - }, - }); + seatRef, + ); - return executor.executeOffer(offerSpec, seatRef => { state.liveOffers.init(offerSpec.id, offerSpec); - facets.helper.publishCurrentState(); state.liveOfferSeats.init(offerSpec.id, seatRef); - }); + + // publish the live offers + facets.helper.publishCurrentState(); + + // 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, + }); + } + + if (offerSpec?.proposal?.give) { + facets.payments + .tryReclaimingWithdrawnPayments(offerSpec.id) + .catch(e => + facets.helper.logWalletError( + 'recovery failed reclaiming payments', + e, + ), + ); + } + + if (seatRef) { + void E.when(E(seatRef).hasExited(), hasExited => { + if (!hasExited) { + void E(seatRef).tryExit(); + } + }); + } + + 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 +947,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,27 +999,41 @@ 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(), }, }); }, + // one-time use function. Remove this and repairUnwatchedSeats once the + // repair has taken place. + repairWalletForIncarnation2(key) { + const { facets } = this; + + if (key !== shared.secretWalletFactoryKey) { + return; + } + + void facets.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..920ed6251f3f 100644 --- a/packages/smart-wallet/src/walletFactory.js +++ b/packages/smart-wallet/src/walletFactory.js @@ -29,6 +29,9 @@ export const privateArgsShape = harden( ), ); +const WALLETS_BY_ADDRESS = 'walletsByAddress'; +const UPGRADE_TO_INCARNATION_TWO = 'upgrade to incarnation two'; + /** * Provide a NameHub for this address and insert depositFacet only if not * already done. @@ -129,7 +132,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 /** @@ -142,14 +145,14 @@ export const makeAssetRegistry = assetPublisher => { * @param {import('@agoric/vat-data').Baggage} baggage */ export const prepare = async (zcf, privateArgs, baggage) => { - const upgrading = baggage.has('walletsByAddress'); + const upgrading = baggage.has(WALLETS_BY_ADDRESS); const { agoricNames, board, assetPublisher } = zcf.getTerms(); const zoe = zcf.getZoeService(); const { storageNode, walletBridgeManager, walletReviver } = privateArgs; /** @type {MapStore} */ - const walletsByAddress = provideDurableMapStore(baggage, 'walletsByAddress'); + const walletsByAddress = provideDurableMapStore(baggage, WALLETS_BY_ADDRESS); const provider = makeAtomicProvider(walletsByAddress); const handleWalletAction = makeExo( @@ -220,6 +223,13 @@ export const prepare = async (zcf, privateArgs, baggage) => { const registry = makeAssetRegistry(assetPublisher); + // An object known only to walletFactory and smartWallets. The WalletFactory + // only has the self facet for the pre-existing wallets that must be repaired. + // Self is too accessible, so use of the repair function requires use of a + // secret that clients won't have. This can be removed once the upgrade has + // taken place. + const upgradeToIncarnation2Key = harden({}); + const shared = harden({ agoricNames, invitationBrand, @@ -228,6 +238,7 @@ export const prepare = async (zcf, privateArgs, baggage) => { publicMarshaller, registry, zoe, + secretWalletFactoryKey: upgradeToIncarnation2Key, }); /** @@ -237,6 +248,19 @@ export const prepare = async (zcf, privateArgs, baggage) => { */ const makeSmartWallet = prepareSmartWallet(baggage, shared); + // One time repair for incarnation 2. We're adding WatchedPromises to allow + // wallets to durably monitor offer outcomes, but wallets that already exist + // need to be backfilled. This code needs to run once at the beginning of + // incarnation 2, and then shouldn't be needed again. + if (!baggage.has(UPGRADE_TO_INCARNATION_TWO)) { + trace('Wallet Factory upgrading to incarnation 2'); + + for (const wallet of walletsByAddress.values()) { + wallet.repairWalletForIncarnation2(upgradeToIncarnation2Key); + } + baggage.init(UPGRADE_TO_INCARNATION_TWO, 'done'); + } + const creatorFacet = prepareExo( baggage, 'walletFactoryCreator', 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/swingsetTests/upgradeWalletFactory/walletFactory-V2.js b/packages/smart-wallet/test/swingsetTests/upgradeWalletFactory/walletFactory-V2.js index dba7ecc53691..2d5a606e15c3 100644 --- a/packages/smart-wallet/test/swingsetTests/upgradeWalletFactory/walletFactory-V2.js +++ b/packages/smart-wallet/test/swingsetTests/upgradeWalletFactory/walletFactory-V2.js @@ -78,6 +78,13 @@ export const prepare = async (zcf, privateArgs, baggage) => { const registry = makeAssetRegistry(assetPublisher); + // An object known only to walletFactory and smartWallets. The WalletFactory + // only has the self facet for the pre-existing wallets that must be repaired. + // Self is too accessible, so use of the repair function requires use of a + // secret that clients won't have. This can be removed once the upgrade has + // taken place. + const upgradeToIncarnation2Key = harden({}); + const shared = harden({ agoricNames, invitationBrand, @@ -86,6 +93,7 @@ export const prepare = async (zcf, privateArgs, baggage) => { publicMarshaller, registry, zoe, + secretWalletFactoryKey: upgradeToIncarnation2Key, }); /** 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