diff --git a/packages/boot/test/bootstrapTests/test-vaults-integration.ts b/packages/boot/test/bootstrapTests/test-vaults-integration.ts index eda3c165a98..9d66c9a6f3b 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 30bd4092dd2..6d3151d4721 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/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 00000000000..515cb75df3b --- /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/builders/scripts/smart-wallet/build-wallet-factory2-upgrade.js b/packages/builders/scripts/smart-wallet/build-wallet-factory2-upgrade.js new file mode 100644 index 00000000000..bb2ed3bce75 --- /dev/null +++ b/packages/builders/scripts/smart-wallet/build-wallet-factory2-upgrade.js @@ -0,0 +1,29 @@ +import { makeHelpers } from '@agoric/deploy-script-support'; + +/** + * @file + * `agoric run scripts/smart-wallet/build-wallet-factory2-upgrade.js` + * 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/inter-protocol/src/price/roundsManager.js b/packages/inter-protocol/src/price/roundsManager.js index 32e58536b3b..7e5fc95f52a 100644 --- a/packages/inter-protocol/src/price/roundsManager.js +++ b/packages/inter-protocol/src/price/roundsManager.js @@ -438,8 +438,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 5021e41968c..0cc96982794 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 f9a290bdad6..bb3a8fb5b27 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 7037d537da8..2ab454c1e87 100644 --- a/packages/inter-protocol/test/smartWallet/test-psm-integration.js +++ b/packages/inter-protocol/test/smartWallet/test-psm-integration.js @@ -192,11 +192,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 => { @@ -383,6 +378,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/src/offerWatcher.js b/packages/smart-wallet/src/offerWatcher.js new file mode 100644 index 00000000000..85ea2061952 --- /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 72c74a1d2d6..e892bf2b03e 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.js').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.js').InvitationMakers, publicSubscribers: import('./types.js').PublicSubscribers | import('@agoric/zoe/src/contractSupport/index.js').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 cb79a3af724..00000000000 --- 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 00000000000..60252d54616 --- /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/smartWallet.js b/packages/smart-wallet/src/smartWallet.js index 1c08267c3db..d677ab5b86f 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,32 @@ 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().rest(M.arrayOf(M.any())).returns(), + logWalletError: M.call().rest(M.arrayOf(M.any())).returns(), + getLiveOfferPayments: M.call().returns(M.remotable('mapStore')), }), + deposit: M.interface('depositFacetI', { receive: M.callWhen(M.await(M.eref(PaymentShape))).returns(AmountShape), }), + payments: M.interface('payments support', { + withdrawGive: M.call( + AmountKeywordRecordShape, + M.or(M.number(), M.string()), + ).returns(PaymentPKeywordRecordShape), + tryReclaimingWithdrawnPayments: M.call( + M.or(M.number(), 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 +395,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 +419,7 @@ export const prepareSmartWallet = (baggage, shared) => { * @type {(id: string) => void} */ assertUniqueOfferId(id) { + const { facets } = this; const { liveOffers, liveOfferSeats, @@ -365,6 +430,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 +478,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 +488,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 +498,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 +510,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 +562,154 @@ 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. This 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, liveOffers, liveOfferSeats } = + state; + const { zoe, agoricNames, 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 +725,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 +757,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.js').InvitationMakers, publicSubscribers?: import('./types.js').PublicSubscribers | import('@agoric/zoe/src/contractSupport/index.js').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 +951,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,14 +1003,18 @@ 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; + const { currentRecorderKit, updateRecorderKit } = state; + return harden({ current: { description: 'Current state of wallet', @@ -739,6 +1028,21 @@ export const prepareSmartWallet = (baggage, shared) => { }, }); }, + /** + * one-time use function. Remove this and repairUnwatchedSeats once the + * repair has taken place. + * + * @param {object} key + */ + repairWalletForIncarnation2(key) { + const { facets } = this; + + if (key !== shared.secretWalletFactoryKey) { + return; + } + + void facets.helper.repairUnwatchedSeats(); + }, }, }, { diff --git a/packages/smart-wallet/src/walletFactory.js b/packages/smart-wallet/src/walletFactory.js index 2751631f37a..58d8aa0b067 100644 --- a/packages/smart-wallet/src/walletFactory.js +++ b/packages/smart-wallet/src/walletFactory.js @@ -2,6 +2,9 @@ * @file Wallet Factory * * Contract to make smart wallets. + * + * Note: The upgrade test uses a slightly modified copy of this file. When the + * interface changes here, that will also need to change. */ import { makeTracer, WalletName } from '@agoric/internal'; @@ -29,6 +32,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 +135,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 +148,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 +226,15 @@ 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 +243,7 @@ export const prepare = async (zcf, privateArgs, baggage) => { publicMarshaller, registry, zoe, + secretWalletFactoryKey: upgradeToIncarnation2Key, }); /** @@ -237,6 +253,22 @@ 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'); + + // This could take a while, depending on how many outstanding wallets exist. + // The current plan is that it will run exactly once, and inside an upgrade + // handler, between blocks. + 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 256727bfd61..ef982ce9882 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 f774ea76fa1..b3282f9f38b 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, }); /**