diff --git a/packages/boot/test/bootstrapTests/test-walletSurvivesZoeRestart.ts b/packages/boot/test/bootstrapTests/test-walletSurvivesZoeRestart.ts new file mode 100644 index 00000000000..6d3151d4721 --- /dev/null +++ b/packages/boot/test/bootstrapTests/test-walletSurvivesZoeRestart.ts @@ -0,0 +1,115 @@ +/** @file Bootstrap test of liquidation across multiple collaterals */ +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import process from 'process'; +import type { TestFn } from 'ava'; + +import { BridgeHandler } from '@agoric/vats'; +import { Offers } from '@agoric/inter-protocol/src/clientSupport.js'; +import { + LiquidationTestContext, + makeLiquidationTestContext, + LiquidationSetup, +} from '../../tools/liquidation.ts'; + +const test = anyTest as TestFn; + +const setup: LiquidationSetup = { + vaults: [ + { + atom: 15, + ist: 100, + debt: 100.5, + }, + ], + bids: [ + { + give: '80IST', + discount: 0.1, + }, + ], + price: { + starting: 12.34, + trigger: 9.99, + }, + auction: { + start: { + collateral: 45, + debt: 309.54, + }, + end: { + collateral: 9.659301, + debt: 0, + }, + }, +}; + +test.before(async t => { + t.context = await makeLiquidationTestContext(t); +}); + +test.after.always(t => { + return t.context.shutdown && t.context.shutdown(); +}); + +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 { EV } = t.context.runUtils; + + const buyer = await walletFactoryDriver.provideSmartWallet('agoric1buyer'); + + const buildAndExecuteProposal = async (packageSpec: string) => { + const proposal = await buildProposal(packageSpec); + + for await (const bundle of proposal.bundles) { + await controller.validateAndInstallBundle(bundle); + } + + const bridgeMessage = { + type: 'CORE_EVAL', + evals: proposal.evals, + }; + + const coreEvalBridgeHandler: ERef = await EV.vat( + 'bootstrap', + ).consumeItem('coreEvalBridgeHandler'); + await EV(coreEvalBridgeHandler).fromBridge(bridgeMessage); + }; + + await setupVaults(collateralBrandKey, managerIndex, setup); + + // restart Zoe + + // /////// Upgrading //////////////////////////////// + await buildAndExecuteProposal('@agoric/builders/scripts/vats/upgrade-zoe.js'); + + t.like(await buyer.getLatestUpdateRecord(), { + currentAmount: { + // brand from EV() doesn't compare correctly + // brand: invitationBrand, + value: [], + }, + updated: 'balance', + }); + + await buyer.executeOfferMaker(Offers.vaults.OpenVault, { + offerId: 'open1', + collateralBrandKey: 'ATOM', + wantMinted: 5.0, + giveCollateral: 9.0, + }); + + t.like(buyer.getLatestUpdateRecord(), { + updated: 'offerStatus', + status: { id: 'open1', numWantsSatisfied: 1 }, + }); +}); diff --git a/packages/inter-protocol/test/smartWallet/test-oracle-integration.js b/packages/inter-protocol/test/smartWallet/test-oracle-integration.js index d7a13525b86..e1873141935 100644 --- a/packages/inter-protocol/test/smartWallet/test-oracle-integration.js +++ b/packages/inter-protocol/test/smartWallet/test-oracle-integration.js @@ -149,7 +149,7 @@ const acceptInvitation = async (wallet, priceAggregator) => { let pushPriceCounter = 0; /** - * @param {*} 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 @@ -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 diff --git a/packages/inter-protocol/test/smartWallet/test-psm-integration.js b/packages/inter-protocol/test/smartWallet/test-psm-integration.js index e5902ed20fe..84053f87140 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 b78ae29175f..f49c2e02216 100644 --- a/packages/smart-wallet/package.json +++ b/packages/smart-wallet/package.json @@ -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", @@ -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", "@agoric/ertp": "^0.16.3-u13.0", "@agoric/internal": "^0.4.0-u13.0", "@agoric/notifier": "^0.6.3-u13.0", diff --git a/packages/smart-wallet/src/offerWatcher.js b/packages/smart-wallet/src/offerWatcher.js new file mode 100644 index 00000000000..5c7511c54af --- /dev/null +++ b/packages/smart-wallet/src/offerWatcher.js @@ -0,0 +1,246 @@ +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, + * numWantsWatcher: OfferPromiseWatcher, + * paymentWatcher: OfferPromiseWatcher, + * }} 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; + 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 e685bb2dbb7..a6f799200b1 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,173 +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').RemoteInvitationMakers, 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. - - // eslint-disable-next-line @jessie.js/no-nested-await -- unconditional - 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 5fc50d12303..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..3a1911c3fa6 --- /dev/null +++ b/packages/smart-wallet/src/proposals/upgrade-wallet-factory2-proposal.js @@ -0,0 +1,59 @@ +// @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, + ]); + // @ts-expect-error missing type declaration? + 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 c7f57f969f0..f38d832a964 100644 --- a/packages/smart-wallet/src/smartWallet.js +++ b/packages/smart-wallet/src/smartWallet.js @@ -1,5 +1,4 @@ -// backported types are out of sync -// @ts-nocheck +import { E } from '@endo/far'; import { AmountShape, BrandShape, @@ -8,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 { @@ -22,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; @@ -42,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 */ @@ -83,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 */ @@ -108,18 +135,15 @@ const trace = makeTracer('SmrtWlt'); * brand: Brand, * displayInfo: DisplayInfo, * issuer: Issuer, - * petname: import('./types').Petname + * petname: import('./types.js').Petname * }} BrandDescriptor * For use by clients to describe brands to users. Includes `displayInfo` to save a remote call. */ -// imports -/** @typedef {import('./types').RemotePurse} RemotePurse */ - /** * @typedef {{ * address: string, - * bank: ERef, + * bank: ERef, * currentStorageNode: StorageNode, * invitationPurse: Purse<'set'>, * walletStorageNode: StorageNode, @@ -134,6 +158,7 @@ const trace = makeTracer('SmrtWlt'); * invitationDisplayInfo: DisplayInfo, * publicMarshaller: Marshaller, * zoe: ERef, + * secretWalletFactoryKey: any, * }} SharedParams * * @typedef {ImmutableState & MutableState} State @@ -144,14 +169,15 @@ const trace = makeTracer('SmrtWlt'); * * @typedef {Readonly>, - * offerToInvitationMakers: MapStore, + * offerToInvitationMakers: MapStore, * offerToPublicSubscriberPaths: MapStore>, * offerToUsedInvitation: MapStore, - * purseBalances: MapStore, + * 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 @@ -165,7 +191,7 @@ const trace = makeTracer('SmrtWlt'); * TODO: consider moving to nameHub.js? * * @param {unknown} target - passable Key - * @param {ERef} nameHub + * @param {ERef} nameHub */ const namesOf = async (target, nameHub) => { const entries = await E(nameHub).entries(); @@ -223,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(), }), ); @@ -237,8 +269,9 @@ export const prepareSmartWallet = (baggage, shared) => { return store; }); + const makeOfferWatcher = prepareOfferWatcher(baggage); + /** - * * @param {UniqueParams} unique * @returns {State} */ @@ -302,6 +335,9 @@ export const prepareSmartWallet = (baggage, shared) => { liveOfferSeats: makeScalarBigMapStore('live offer seats', { durable: true, }), + liveOfferPayments: makeScalarBigMapStore('live offer payments', { + durable: true, + }), }; return { @@ -320,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 tighter guard for a bigMapStore than M.any()? + 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()), @@ -337,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(), }), }; @@ -360,6 +417,7 @@ export const prepareSmartWallet = (baggage, shared) => { * @type {(id: string) => void} */ assertUniqueOfferId(id) { + const { facets } = this; const { liveOffers, liveOfferSeats, @@ -370,13 +428,14 @@ 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); !used || Fail`cannot re-use offer id ${id}`; }, /** - * @param {RemotePurse} purse + * @param {Purse} purse * @param {Amount} balance */ updateBalance(purse, balance) { @@ -415,9 +474,9 @@ export const prepareSmartWallet = (baggage, shared) => { }); }, - /** @type {(purse: ERef) => Promise} */ + /** @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 @@ -427,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, ), @@ -438,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, + ); }, }); }, @@ -447,12 +508,12 @@ 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. * * @param {Brand} brand - * @param {ERef} known - namehub with brand, issuer branches + * @param {ERef} known - namehub with brand, issuer branches * @returns {Promise} undefined if brand is not known */ async getPurseIfKnownBrand(brand, known) { @@ -499,6 +560,159 @@ 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 } = state; + const { liveOffers, liveOfferSeats } = state; + const { zoe, agoricNames } = shared; + const { invitationBrand, invitationIssuer } = shared; + + const invitationFromSpec = makeInvitationsHelper( + zoe, + agoricNames, + invitationBrand, + invitationPurse, + state.offerToInvitationMakers.get, + ); + + const watcherPromises = []; + 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); + watcherPromises.push( + E.when( + E(invitationIssuer).getAmountOf(invitation), + invitationAmount => { + const watcher = makeOfferWatcher( + facets.helper, + facets.deposit, + offerSpec, + address, + invitationAmount, + seat, + ); + return watchOfferOutcomes(watcher, seat); + }, + ), + ); + trace(`Repaired seat ${seatId} for wallet ${address}`); + } + + await Promise.all(watcherPromises); + }, + + /** @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. @@ -514,9 +728,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 @@ -542,119 +760,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 RemotePurse cast - return E(bank).getPurse(brand); - } else if (invitationBrand === brand) { - // @ts-expect-error RemotePurse cast - 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').RemoteInvitationMakers, 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. */ @@ -672,14 +954,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 }, @@ -724,14 +1006,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', @@ -745,6 +1031,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(); + }, }, }, { @@ -752,7 +1053,6 @@ export const prepareSmartWallet = (baggage, shared) => { const { invitationPurse } = state; const { helper } = facets; - // @ts-expect-error RemotePurse cast void helper.watchPurse(invitationPurse); }, }, diff --git a/packages/smart-wallet/src/walletFactory.js b/packages/smart-wallet/src/walletFactory.js index bc9cb0c8c48..8e279a3d2a7 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 22a0a62bd80..0e074e9df02 100644 --- a/packages/smart-wallet/test/gameAssetContract.js +++ b/packages/smart-wallet/test/gameAssetContract.js @@ -25,7 +25,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 dba7ecc5369..2d5a606e15c 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/vats/package.json b/packages/vats/package.json index 06702a2de4b..dd4ab68d941 100644 --- a/packages/vats/package.json +++ b/packages/vats/package.json @@ -29,6 +29,7 @@ "license": "Apache-2.0", "dependencies": { "@agoric/assert": "^0.6.1-u11wf.0", + "@agoric/deploy-script-support": "^0.10.4-u13.0", "@agoric/ertp": "^0.16.3-u13.0", "@agoric/governance": "^0.10.4-u13.0", "@agoric/inter-protocol": "^0.16.2-u13.0", @@ -46,10 +47,13 @@ "@endo/init": "0.5.56", "@endo/marshal": "0.8.5", "@endo/nat": "4.1.27", + "@endo/patterns": "^0.2.2", "@endo/promise-kit": "0.2.56", + "import-meta-resolve": "^2.2.1", "jessie.js": "^0.3.2" }, "devDependencies": { + "@agoric/vats": "^0.15.2-u13.0", "@agoric/cosmic-swingset": "^0.42.0-u13.0", "@agoric/deploy-script-support": "^0.10.4-u13.0", "@agoric/smart-wallet": "^0.5.4-u13.0", diff --git a/packages/vats/scripts/build-wallet-factory2-upgrade.js b/packages/vats/scripts/build-wallet-factory2-upgrade.js new file mode 100644 index 00000000000..bb2ed3bce75 --- /dev/null +++ b/packages/vats/scripts/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/vats/test/bootstrapTests/test-vaults-integration.js b/packages/vats/test/bootstrapTests/test-vaults-integration.js index 084ba75e8c8..7f04db775f8 100644 --- a/packages/vats/test/bootstrapTests/test-vaults-integration.js +++ b/packages/vats/test/bootstrapTests/test-vaults-integration.js @@ -1,7 +1,4 @@ // @ts-check -/** - * @file Bootstrap test integration vaults with smart-wallet - */ import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import { Fail } from '@agoric/assert'; @@ -13,15 +10,18 @@ import { makeMarshal } from '@endo/marshal'; import { makeAgoricNamesRemotesFromFakeStorage, slotToBoardRemote, -} from '../../tools/board-utils.js'; -import { makeWalletFactoryDriver } from './drivers.js'; +} from '@agoric/vats/tools/board-utils.js'; + import { makeSwingsetTestKit } from './supports.js'; +import { makeWalletFactoryDriver } from './drivers.js'; /** * @type {import('ava').TestFn>>} */ const test = anyTest; +/** @file Bootstrap test integration vaults with smart-wallet */ + // presently all these tests use one collateral manager const collateralBrandKey = 'ATOM'; @@ -140,6 +140,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; @@ -155,7 +157,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( @@ -175,6 +178,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, @@ -185,10 +189,9 @@ test('close vault', async t => { }, 'open-vault', ), - { - message, - }, + { message }, ); + t.like(wd.getLatestUpdateRecord(), { updated: 'offerStatus', status: { @@ -208,10 +211,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), @@ -230,6 +236,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/vats/test/bootstrapTests/walletFactory.ts b/packages/vats/test/bootstrapTests/walletFactory.ts new file mode 100644 index 00000000000..515cb75df3b --- /dev/null +++ b/packages/vats/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 +>;