diff --git a/packages/smart-wallet/src/offerWatcher.js b/packages/smart-wallet/src/offerWatcher.js index 1d31a7ad511..eb3aa0cc8db 100644 --- a/packages/smart-wallet/src/offerWatcher.js +++ b/packages/smart-wallet/src/offerWatcher.js @@ -90,6 +90,7 @@ const offerWatcherGuard = harden({ .optional(M.record()) .returns(), publishResult: M.call(M.any()).returns(), + handleError: M.call(M.error()).returns(), }), paymentWatcher: M.interface('paymentWatcher', { onFulfilled: M.call(PaymentPKeywordRecordShape, SeatShape).returns( @@ -134,6 +135,9 @@ export const prepareOfferWatcher = baggage => { }), { helper: { + /** + * @param {Record} offerStatusUpdates + */ updateStatus(offerStatusUpdates) { const { state } = this; state.status = harden({ ...state.status, ...offerStatusUpdates }); @@ -189,6 +193,22 @@ export const prepareOfferWatcher = baggage => { facets.helper.updateStatus({ result: UNPUBLISHED_RESULT }); } }, + /** + * Called when the offer result promise rejects. The other two watchers + * are waiting for particular values out of Zoe but they settle at the same time + * and don't need their own error handling. + * @param {Error} err + */ + handleError(err) { + const { facets } = this; + facets.helper.updateStatus({ error: err.toString() }); + const { seatRef } = this.state; + void E.when(E(seatRef).hasExited(), hasExited => { + if (!hasExited) { + void E(seatRef).tryExit(); + } + }); + }, }, /** @type {OutcomeWatchers['paymentWatcher']} */ @@ -205,13 +225,17 @@ export const prepareOfferWatcher = baggage => { facets.helper.updateStatus({ payouts: amounts }); }, /** - * @param {Error} err + * If promise disconnected, watch again. Or if there's an Error, handle it. + * + * @param {Error | import('@agoric/internal/src/upgrade-api.js').DisconnectionObject} reason * @param {UserSeat} seat */ - onRejected(err, seat) { + onRejected(reason, seat) { const { facets } = this; - if (isUpgradeDisconnection(err)) { + if (isUpgradeDisconnection(reason)) { void watchForPayout(facets, seat); + } else { + facets.helper.handleError(reason); } }, }, @@ -223,13 +247,17 @@ export const prepareOfferWatcher = baggage => { facets.helper.publishResult(result); }, /** - * @param {Error} err + * If promise disconnected, watch again. Or if there's an Error, handle it. + * + * @param {Error | import('@agoric/internal/src/upgrade-api.js').DisconnectionObject} reason * @param {UserSeat} seat */ - onRejected(err, seat) { + onRejected(reason, seat) { const { facets } = this; - if (isUpgradeDisconnection(err)) { + if (isUpgradeDisconnection(reason)) { void watchForOfferResult(facets, seat); + } else { + facets.helper.handleError(reason); } }, }, @@ -242,12 +270,18 @@ export const prepareOfferWatcher = baggage => { facets.helper.updateStatus({ numWantsSatisfied: numSatisfied }); }, /** - * @param {Error} err + * If promise disconnected, watch again. + * + * Errors are handled by the paymentWatcher because numWantsSatisfied() + * and getPayouts() settle the same (they await the same promise and + * then synchronously return a local value). + * + * @param {Error | import('@agoric/internal/src/upgrade-api.js').DisconnectionObject} reason * @param {UserSeat} seat */ - onRejected(err, seat) { + onRejected(reason, seat) { const { facets } = this; - if (isUpgradeDisconnection(err)) { + if (isUpgradeDisconnection(reason)) { void watchForNumWants(facets, seat); } }, diff --git a/packages/smart-wallet/src/smartWallet.js b/packages/smart-wallet/src/smartWallet.js index 194a846e802..d1b1323dfb7 100644 --- a/packages/smart-wallet/src/smartWallet.js +++ b/packages/smart-wallet/src/smartWallet.js @@ -456,6 +456,7 @@ export const prepareSmartWallet = (baggage, shared) => { }), }; + // TODO move to top level so its type can be exported /** * Make the durable object to return, but taking some parameters that are awaited by a wrapping function. * This is necessary because the class kit construction helpers, `initState` and `finish` run synchronously @@ -856,6 +857,10 @@ export const prepareSmartWallet = (baggage, shared) => { payments: { /** + * Withdraw the offered amount from the appropriate purse of this wallet. + * + * Save its amount in liveOfferPayments in case we need to reclaim the payment. + * * @param {AmountKeywordRecord} give * @param {OfferId} offerId * @returns {PaymentPKeywordRecord} @@ -871,8 +876,8 @@ export const prepareSmartWallet = (baggage, shared) => { .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. + // Add each payment amount to brandPaymentRecord as it is withdrawn. If + // there's an error later, we can use it to redeposit the correct amount. return objectMap(give, amount => { /** @type {Promise} */ const purseP = facets.helper.purseForBrand(amount.brand); @@ -893,19 +898,27 @@ export const prepareSmartWallet = (baggage, shared) => { }); }, + /** + * Find the live payments for the offer and deposit them back in the appropriate purses. + * + * @param {OfferId} offerId + * @returns {Promise} + */ async tryReclaimingWithdrawnPayments(offerId) { const { facets } = this; + await null; + const liveOfferPayments = facets.helper.getLiveOfferPayments(); if (liveOfferPayments.has(offerId)) { const brandPaymentRecord = liveOfferPayments.get(offerId); if (!brandPaymentRecord) { - return Promise.resolve(undefined); + return; } // Use allSettled to ensure we attempt all the deposits, regardless of // individual rejections. - return Promise.allSettled( - Array.from(brandPaymentRecord.entries()).map(async ([b, p]) => { + await Promise.allSettled( + Array.from(brandPaymentRecord.entries()).map(([b, p]) => { // Wait for the withdrawal to complete. This protects against a // race when updating paymentToPurse. const purseP = facets.helper.purseForBrand(b); @@ -993,14 +1006,15 @@ export const prepareSmartWallet = (baggage, shared) => { // await so that any errors are caught and handled below await watchOfferOutcomes(watcher, seatRef); } catch (err) { - facets.helper.logWalletError('OFFER ERROR:', err); + // This block only runs if the block above fails during one vat incarnation. + facets.helper.logWalletError('IMMEDIATE OFFER ERROR:', err); - // Notify the user + // Update status to observers if (err.upgradeMessage === 'vat upgraded') { // The offer watchers will reconnect. Don't reclaim or exit return; } else if (watcher) { - watcher.helper.updateStatus({ error: err.toString() }); + // The watcher's onRejected will updateStatus() } else { facets.helper.updateStatus({ error: err.toString(), @@ -1008,6 +1022,7 @@ export const prepareSmartWallet = (baggage, shared) => { }); } + // Backstop recovery, in case something very basic fails. if (offerSpec?.proposal?.give) { facets.payments .tryReclaimingWithdrawnPayments(offerSpec.id) @@ -1019,14 +1034,8 @@ export const prepareSmartWallet = (baggage, shared) => { ); } - if (seatRef) { - void E.when(E(seatRef).hasExited(), hasExited => { - if (!hasExited) { - void E(seatRef).tryExit(); - } - }); - } - + // XXX tests rely on throwing immediate errors, not covering the + // error handling in the event the failure is after an upgrade throw err; } },