Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WalletFactory survive upgrades and repair outstanding purses and offers #8773

Merged
merged 10 commits into from
Jan 20, 2024
6 changes: 5 additions & 1 deletion a3p-integration/proposals/a:upgrade-14/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
"releaseNotes": "https://github.com/Agoric/agoric-sdk/releases/tag/agoric-upgrade-14",
"sdkImageTag": "unreleased",
"planName": "agoric-upgrade-14",
"upgradeInfo": {},
"upgradeInfo": {
"coreProposals": [
"@agoric/vats/scripts/build-wallet-factory2-upgrade.js"
]
},
"type": "Software Upgrade Proposal"
},
"type": "module",
Expand Down
8 changes: 8 additions & 0 deletions a3p-integration/proposals/a:upgrade-14/post.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import test from 'ava';
import { getIncarnation } from '@agoric/synthetic-chain/src/lib/vat-status.js';

test(`Smart Wallet vat was upgraded`, async t => {
const incarnation = await getIncarnation('walletFactory');

t.is(incarnation, 2);
});
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ const acceptInvitation = async (wallet, priceAggregator) => {

let pushPriceCounter = 0;
/**
* @param {*} wallet
* @param {import('@agoric/smart-wallet/src/smartWallet.js').SmartWallet} wallet
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MH usually avoids changes that have no runtime impact on the release branch. I'm not sure whether that matters here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a problem in test files

* @param {string} adminOfferId
* @param {import('@agoric/inter-protocol/src/price/roundsManager.js').PriceRound} priceRound
* @returns {Promise<string>} offer id
Expand Down Expand Up @@ -322,6 +322,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
Expand Down
13 changes: 4 additions & 9 deletions packages/inter-protocol/test/smartWallet/test-psm-integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,8 @@ test('null swap', async t => {
t.is(await E.get(getBalanceFor(anchor.brand)).value, 0n);
t.is(await E.get(getBalanceFor(mintedBrand)).value, 0n);

t.deepEqual(currents[0].liveOffers, []);
t.deepEqual(currents[1].liveOffers, []);
t.deepEqual(currents[2].liveOffers, [['nullSwap', offer]]);
t.deepEqual(currents[3].liveOffers, []);
const found = currents.find(c => c.liveOffers.length > 0);
t.deepEqual(found?.liveOffers, [['nullSwap', offer]]);
});

// we test this direction of swap because wanting anchor would require the PSM to have anchor in it first
Expand Down Expand Up @@ -193,11 +191,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 => {
Expand Down Expand Up @@ -384,6 +377,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;
Expand Down
2 changes: 2 additions & 0 deletions packages/smart-wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
},
"devDependencies": {
"@agoric/cosmic-proto": "^0.3.0",
"@agoric/vats": "^0.15.2-u13.0",
"@endo/bundle-source": "2.5.2-upstream-rollup",
"@endo/captp": "3.1.1",
"@endo/init": "0.5.56",
Expand All @@ -26,6 +27,7 @@
"dependencies": {
"@agoric/assert": "^0.6.1-u11wf.0",
"@agoric/casting": "^0.4.3-u13.0",
"@agoric/deploy-script-support": "^0.10.4-u13.0",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if that could be moved to devDependencies.
not sure it matters, much, though...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, which we can punt on for now.

"@agoric/ertp": "^0.16.3-u13.0",
"@agoric/internal": "^0.4.0-u13.0",
"@agoric/notifier": "^0.6.3-u13.0",
Expand Down
248 changes: 248 additions & 0 deletions packages/smart-wallet/src/offerWatcher.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
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 {{ onFulfilled: (any) => any, onRejected: (err: Error, seat: any) => void }} OfferPromiseWatcher<T, [UserSeat]
*/

/**
* @typedef {{
* resultWatcher: OfferPromiseWatcher<unknown>,
* numWantsWatcher: OfferPromiseWatcher<number>,
* paymentWatcher: OfferPromiseWatcher<PaymentPKeywordRecord>,
* }} OutcomeWatchers
*/

/**
* @param {OutcomeWatchers} watchers
* @param {UserSeat} seat
*/
const watchForOfferResult = ({ resultWatcher }, seat) => {
const p = E(seat).getOfferResult();
// @ts-expect-error missing declarations?
watchPromise(p, resultWatcher, seat);
return p;
};

/**
* @param {OutcomeWatchers} watchers
* @param {UserSeat} seat
*/
const watchForNumWants = ({ numWantsWatcher }, seat) => {
const p = E(seat).numWantsSatisfied();
// @ts-expect-error missing declarations?
watchPromise(p, numWantsWatcher, seat);
return p;
};

/**
* @param {OutcomeWatchers} watchers
* @param {UserSeat} seat
*/
const watchForPayout = ({ paymentWatcher }, seat) => {
const p = E(seat).getPayouts();
// @ts-expect-error missing declarations?
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;
if (isUpgradeDisconnection(err)) {
void watchForNumWants(facets, seat);
}
},
},
},
);
};
harden(prepareOfferWatcher);

/** @typedef {ReturnType<typeof prepareOfferWatcher>} MakeOfferWatcher */
Loading
Loading