Skip to content

Commit

Permalink
smart wallet report rejections of durable promises (backport #8998) (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
mhofman authored Feb 27, 2024
2 parents 04b3f07 + 17cd7f0 commit f3e5b2f
Show file tree
Hide file tree
Showing 2 changed files with 68 additions and 25 deletions.
52 changes: 43 additions & 9 deletions packages/smart-wallet/src/offerWatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -134,6 +135,9 @@ export const prepareOfferWatcher = baggage => {
}),
{
helper: {
/**
* @param {Record<string, unknown>} offerStatusUpdates
*/
updateStatus(offerStatusUpdates) {
const { state } = this;
state.status = harden({ ...state.status, ...offerStatusUpdates });
Expand Down Expand Up @@ -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']} */
Expand All @@ -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);
}
},
},
Expand All @@ -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);
}
},
},
Expand All @@ -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);
}
},
Expand Down
41 changes: 25 additions & 16 deletions packages/smart-wallet/src/smartWallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand All @@ -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<Purse>} */
const purseP = facets.helper.purseForBrand(amount.brand);
Expand All @@ -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<void>}
*/
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);
Expand Down Expand Up @@ -993,21 +1006,23 @@ 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(),
...offerSpec,
});
}

// Backstop recovery, in case something very basic fails.
if (offerSpec?.proposal?.give) {
facets.payments
.tryReclaimingWithdrawnPayments(offerSpec.id)
Expand All @@ -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;
}
},
Expand Down

0 comments on commit f3e5b2f

Please sign in to comment.