From 73bed28cb5763f6b2717527a11ddd1549bfbe157 Mon Sep 17 00:00:00 2001 From: Turadg Aleahmad Date: Thu, 30 Nov 2023 09:24:21 -0800 Subject: [PATCH 1/3] feat: smartWallet verstion 2 with watchedPromises pulled offers.js and payments.js into smartWallet.js as they shared plenty of state that needs to be durable in order to be callable from the watchedPromise. build an upgrade proposal; tested in https://github.com/Agoric/agoric-3-proposals/pull/34 --- .../test-walletSurvivesZoeRestart.ts | 115 ++++ .../inter-protocol/src/price/roundsManager.js | 4 +- .../smartWallet/test-oracle-integration.js | 3 +- .../test/smartWallet/test-psm-integration.js | 7 +- packages/smart-wallet/package.json | 2 + packages/smart-wallet/src/offerWatcher.js | 246 ++++++++ packages/smart-wallet/src/offers.js | 174 ------ packages/smart-wallet/src/payments.js | 89 --- .../upgrade-wallet-factory2-proposal.js | 59 ++ .../src/proposals/upgrade-wallet-factory2.js | 29 + packages/smart-wallet/src/smartWallet.js | 526 ++++++++++++++---- packages/smart-wallet/src/walletFactory.js | 30 +- .../smart-wallet/test/gameAssetContract.js | 2 +- .../upgradeWalletFactory/walletFactory-V2.js | 8 + packages/smart-wallet/test/test-addAsset.js | 21 +- .../bootstrapTests/test-vaults-integration.js | 30 +- .../vats/test/bootstrapTests/walletFactory.ts | 51 ++ 17 files changed, 985 insertions(+), 411 deletions(-) create mode 100644 packages/boot/test/bootstrapTests/test-walletSurvivesZoeRestart.ts create mode 100644 packages/smart-wallet/src/offerWatcher.js delete mode 100644 packages/smart-wallet/src/payments.js create mode 100644 packages/smart-wallet/src/proposals/upgrade-wallet-factory2-proposal.js create mode 100644 packages/smart-wallet/src/proposals/upgrade-wallet-factory2.js create mode 100644 packages/vats/test/bootstrapTests/walletFactory.ts 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/src/price/roundsManager.js b/packages/inter-protocol/src/price/roundsManager.js index 7e147d57ee3..2bcca176299 100644 --- a/packages/inter-protocol/src/price/roundsManager.js +++ b/packages/inter-protocol/src/price/roundsManager.js @@ -435,8 +435,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/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/proposals/upgrade-wallet-factory2.js b/packages/smart-wallet/src/proposals/upgrade-wallet-factory2.js new file mode 100644 index 00000000000..a58f856ca44 --- /dev/null +++ b/packages/smart-wallet/src/proposals/upgrade-wallet-factory2.js @@ -0,0 +1,29 @@ +import { makeHelpers } from '@agoric/deploy-script-support'; + +/** + * @file + * `agoric run scripts/vats/upgrade-wallet-factory2.js | tee run-report.txt` + * 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/smart-wallet/src/smartWallet.js b/packages/smart-wallet/src/smartWallet.js index c7f57f969f0..b5de9f0a41c 100644 --- a/packages/smart-wallet/src/smartWallet.js +++ b/packages/smart-wallet/src/smartWallet.js @@ -1,5 +1,6 @@ // backported types are out of sync // @ts-nocheck +import { E } from '@endo/far'; import { AmountShape, BrandShape, @@ -8,7 +9,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 +28,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 +52,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 +112,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 */ @@ -134,6 +163,7 @@ const trace = makeTracer('SmrtWlt'); * invitationDisplayInfo: DisplayInfo, * publicMarshaller: Marshaller, * zoe: ERef, + * secretWalletFactoryKey: any, * }} SharedParams * * @typedef {ImmutableState & MutableState} State @@ -150,8 +180,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 @@ -223,6 +254,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 +274,9 @@ export const prepareSmartWallet = (baggage, shared) => { return store; }); + const makeOfferWatcher = prepareOfferWatcher(baggage); + /** - * * @param {UniqueParams} unique * @returns {State} */ @@ -302,6 +340,9 @@ export const prepareSmartWallet = (baggage, shared) => { liveOfferSeats: makeScalarBigMapStore('live offer seats', { durable: true, }), + liveOfferPayments: makeScalarBigMapStore('live offer payments', { + durable: true, + }), }; return { @@ -320,10 +361,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 better guard for a bigMapStore? + 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 +398,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 +422,7 @@ export const prepareSmartWallet = (baggage, shared) => { * @type {(id: string) => void} */ assertUniqueOfferId(id) { + const { facets } = this; const { liveOffers, liveOfferSeats, @@ -370,6 +433,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); @@ -417,7 +481,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 @@ -427,8 +491,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 +501,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,7 +513,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. * @@ -499,6 +565,152 @@ 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. The 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; + + 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 = + 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. @@ -514,9 +726,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 +758,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 +952,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,27 +1004,41 @@ 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; + return harden({ current: { description: 'Current state of wallet', - subscriber: currentRecorderKit.subscriber, - storagePath: currentRecorderKit.recorder.getStoragePath(), + subscriber: state.currentRecorderKit.subscriber, + storagePath: state.currentRecorderKit.recorder.getStoragePath(), }, updates: { description: 'Changes to wallet', - subscriber: updateRecorderKit.subscriber, - storagePath: updateRecorderKit.recorder.getStoragePath(), + subscriber: state.updateRecorderKit.subscriber, + storagePath: state.updateRecorderKit.recorder.getStoragePath(), }, }); }, + // one-time use function. Remove this and repairUnwatchedSeats once the + // repair has taken place. + 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 bc9cb0c8c48..920ed6251f3 100644 --- a/packages/smart-wallet/src/walletFactory.js +++ b/packages/smart-wallet/src/walletFactory.js @@ -29,6 +29,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 +132,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 +145,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 +223,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, @@ -228,6 +238,7 @@ export const prepare = async (zcf, privateArgs, baggage) => { publicMarshaller, registry, zoe, + secretWalletFactoryKey: upgradeToIncarnation2Key, }); /** @@ -237,6 +248,19 @@ 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'); + + 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/smart-wallet/test/test-addAsset.js b/packages/smart-wallet/test/test-addAsset.js index ef24b961c77..b81eeda5b85 100644 --- a/packages/smart-wallet/test/test-addAsset.js +++ b/packages/smart-wallet/test/test-addAsset.js @@ -15,7 +15,7 @@ import { makeDefaultTestContext } from './contexts.js'; import { ActionType, headValue, makeMockTestSpace } from './supports.js'; import { makeImportContext } from '../src/marshal-contexts.js'; -const { Fail } = assert; +const { Fail, quote: q } = assert; const importSpec = spec => importMetaResolve(spec, import.meta.url).then(u => new URL(u).pathname); @@ -420,8 +420,10 @@ test.serial('trading in non-vbank asset: game real-estate NFTs', async t => { /** @type {import('../src/smartWallet.js').UpdateRecord} */ const update = await headValue(updates); - assert(update.updated === 'offerStatus'); - // t.log(update.status); + assert( + update.updated === 'offerStatus', + `Should have had "updated":"offerStatus", had "${q(update)}"`, + ); t.like(update, { updated: 'offerStatus', status: { @@ -435,7 +437,7 @@ test.serial('trading in non-vbank asset: game real-estate NFTs', async t => { const { status: { id, result, payouts }, } = update; - // @ts-expect-error cast value to copyBag + // @ts-expect-error status includes payload. const names = payouts?.Places.value.payload.map(([name, _qty]) => name); t.log(id, 'result:', result, ', payouts:', names.join(', ')); @@ -495,13 +497,15 @@ test.serial('non-vbank asset: give before deposit', async t => { proposal: { give, want }, }); t.log('goofy client: propose to give', choices.join(', ')); - await E(walletBridge).proposeOffer(ctx.fromBoard.toCapData(offer1)); + await t.throwsAsync( + () => E(walletBridge).proposeOffer(ctx.fromBoard.toCapData(offer1)), + { message: /Withdrawal of .* failed because the purse only contained/ }, + ); }; { const addr2 = 'agoric1player2'; const walletUIbridge = makePromiseKit(); - // await eventLoopIteration(); const { simpleProvideWallet, consume, sendToBridge } = t.context; const wallet = simpleProvideWallet(addr2); @@ -511,9 +515,8 @@ test.serial('non-vbank asset: give before deposit', async t => { const mockStorage = await consume.chainStorage; const { aPlayer } = makeScenario(t); - aPlayer(addr2, walletUIbridge, mockStorage, sendToBridge, updates); - const c2 = goofyClient(mockStorage, walletUIbridge.promise); - await t.throwsAsync(c2, { message: /Withdrawal of {.*} failed/ }); + await aPlayer(addr2, walletUIbridge, mockStorage, sendToBridge, updates); + await goofyClient(mockStorage, walletUIbridge.promise); await eventLoopIteration(); // wallet balance was also updated diff --git a/packages/vats/test/bootstrapTests/test-vaults-integration.js b/packages/vats/test/bootstrapTests/test-vaults-integration.js index 084ba75e8c8..8a21dccc46b 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'; @@ -75,9 +75,8 @@ test.after.always(t => { test('metrics path', async t => { const { EV } = t.context.runUtils; // example of awaitVatObject - const vaultFactoryKit = await EV.vat('bootstrap').consumeItem( - 'vaultFactoryKit', - ); + const vaultFactoryKit = + await EV.vat('bootstrap').consumeItem('vaultFactoryKit'); const vfTopics = await EV(vaultFactoryKit.publicFacet).getPublicTopics(); const vfMetricsPath = await EV.get(vfTopics.metrics).storagePath; t.is(vfMetricsPath, 'published.vaultFactory.metrics'); @@ -140,6 +139,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 +156,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 +177,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 +188,9 @@ test('close vault', async t => { }, 'open-vault', ), - { - message, - }, + { message }, ); + t.like(wd.getLatestUpdateRecord(), { updated: 'offerStatus', status: { @@ -208,10 +210,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 +235,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 +>; From 7c1d67772a4f719479ac73eab60f3cf0c97f3caf Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Fri, 15 Dec 2023 15:02:03 -0800 Subject: [PATCH 2/3] chore: clean-ups from review --- .../inter-protocol/src/price/roundsManager.js | 4 +- packages/smart-wallet/src/smartWallet.js | 37 +++++++++++-------- packages/smart-wallet/src/walletFactory.js | 18 ++++++--- packages/smart-wallet/test/test-addAsset.js | 21 +++++------ packages/vats/package.json | 4 ++ .../scripts/build-wallet-factory2-upgrade.js} | 2 +- .../bootstrapTests/test-vaults-integration.js | 5 ++- 7 files changed, 53 insertions(+), 38 deletions(-) rename packages/{smart-wallet/src/proposals/upgrade-wallet-factory2.js => vats/scripts/build-wallet-factory2-upgrade.js} (92%) diff --git a/packages/inter-protocol/src/price/roundsManager.js b/packages/inter-protocol/src/price/roundsManager.js index 2bcca176299..7e147d57ee3 100644 --- a/packages/inter-protocol/src/price/roundsManager.js +++ b/packages/inter-protocol/src/price/roundsManager.js @@ -435,10 +435,8 @@ 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/smart-wallet/src/smartWallet.js b/packages/smart-wallet/src/smartWallet.js index b5de9f0a41c..a45b3d56829 100644 --- a/packages/smart-wallet/src/smartWallet.js +++ b/packages/smart-wallet/src/smartWallet.js @@ -372,7 +372,7 @@ export const prepareSmartWallet = (baggage, shared) => { purseForBrand: M.call(BrandShape).returns(M.promise()), logWalletInfo: M.call(M.any()).returns(), logWalletError: M.call(M.any()).returns(), - // XXX is there a better guard for a bigMapStore? + // XXX is there a tighter guard for a bigMapStore than M.any()? getLiveOfferPayments: M.call().returns(M.any()), }), @@ -566,14 +566,16 @@ export const prepareSmartWallet = (baggage, shared) => { 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. The function will - // backfill the solution for offers that were outstanding before the - // transition to incarnation 2 of the smartWallet. + /** + * 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; @@ -1014,22 +1016,27 @@ export const prepareSmartWallet = (baggage, shared) => { }, getPublicTopics() { const { state } = this; + const { currentRecorderKit, updateRecorderKit } = state; return harden({ current: { description: 'Current state of wallet', - subscriber: state.currentRecorderKit.subscriber, - storagePath: state.currentRecorderKit.recorder.getStoragePath(), + subscriber: currentRecorderKit.subscriber, + storagePath: currentRecorderKit.recorder.getStoragePath(), }, updates: { description: 'Changes to wallet', - subscriber: state.updateRecorderKit.subscriber, - storagePath: state.updateRecorderKit.recorder.getStoragePath(), + subscriber: updateRecorderKit.subscriber, + storagePath: updateRecorderKit.recorder.getStoragePath(), }, }); }, - // one-time use function. Remove this and repairUnwatchedSeats once the - // repair has taken place. + /** + * one-time use function. Remove this and repairUnwatchedSeats once the + * repair has taken place. + * + * @param {object} key + */ repairWalletForIncarnation2(key) { const { facets } = this; diff --git a/packages/smart-wallet/src/walletFactory.js b/packages/smart-wallet/src/walletFactory.js index 920ed6251f3..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'; @@ -223,11 +226,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. + /** + * 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({ @@ -255,6 +260,9 @@ export const prepare = async (zcf, privateArgs, baggage) => { 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); } diff --git a/packages/smart-wallet/test/test-addAsset.js b/packages/smart-wallet/test/test-addAsset.js index b81eeda5b85..ef24b961c77 100644 --- a/packages/smart-wallet/test/test-addAsset.js +++ b/packages/smart-wallet/test/test-addAsset.js @@ -15,7 +15,7 @@ import { makeDefaultTestContext } from './contexts.js'; import { ActionType, headValue, makeMockTestSpace } from './supports.js'; import { makeImportContext } from '../src/marshal-contexts.js'; -const { Fail, quote: q } = assert; +const { Fail } = assert; const importSpec = spec => importMetaResolve(spec, import.meta.url).then(u => new URL(u).pathname); @@ -420,10 +420,8 @@ test.serial('trading in non-vbank asset: game real-estate NFTs', async t => { /** @type {import('../src/smartWallet.js').UpdateRecord} */ const update = await headValue(updates); - assert( - update.updated === 'offerStatus', - `Should have had "updated":"offerStatus", had "${q(update)}"`, - ); + assert(update.updated === 'offerStatus'); + // t.log(update.status); t.like(update, { updated: 'offerStatus', status: { @@ -437,7 +435,7 @@ test.serial('trading in non-vbank asset: game real-estate NFTs', async t => { const { status: { id, result, payouts }, } = update; - // @ts-expect-error status includes payload. + // @ts-expect-error cast value to copyBag const names = payouts?.Places.value.payload.map(([name, _qty]) => name); t.log(id, 'result:', result, ', payouts:', names.join(', ')); @@ -497,15 +495,13 @@ test.serial('non-vbank asset: give before deposit', async t => { proposal: { give, want }, }); t.log('goofy client: propose to give', choices.join(', ')); - await t.throwsAsync( - () => E(walletBridge).proposeOffer(ctx.fromBoard.toCapData(offer1)), - { message: /Withdrawal of .* failed because the purse only contained/ }, - ); + await E(walletBridge).proposeOffer(ctx.fromBoard.toCapData(offer1)); }; { const addr2 = 'agoric1player2'; const walletUIbridge = makePromiseKit(); + // await eventLoopIteration(); const { simpleProvideWallet, consume, sendToBridge } = t.context; const wallet = simpleProvideWallet(addr2); @@ -515,8 +511,9 @@ test.serial('non-vbank asset: give before deposit', async t => { const mockStorage = await consume.chainStorage; const { aPlayer } = makeScenario(t); - await aPlayer(addr2, walletUIbridge, mockStorage, sendToBridge, updates); - await goofyClient(mockStorage, walletUIbridge.promise); + aPlayer(addr2, walletUIbridge, mockStorage, sendToBridge, updates); + const c2 = goofyClient(mockStorage, walletUIbridge.promise); + await t.throwsAsync(c2, { message: /Withdrawal of {.*} failed/ }); await eventLoopIteration(); // wallet balance was also updated 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/smart-wallet/src/proposals/upgrade-wallet-factory2.js b/packages/vats/scripts/build-wallet-factory2-upgrade.js similarity index 92% rename from packages/smart-wallet/src/proposals/upgrade-wallet-factory2.js rename to packages/vats/scripts/build-wallet-factory2-upgrade.js index a58f856ca44..bb2ed3bce75 100644 --- a/packages/smart-wallet/src/proposals/upgrade-wallet-factory2.js +++ b/packages/vats/scripts/build-wallet-factory2-upgrade.js @@ -2,7 +2,7 @@ import { makeHelpers } from '@agoric/deploy-script-support'; /** * @file - * `agoric run scripts/vats/upgrade-wallet-factory2.js | tee run-report.txt` + * `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. */ diff --git a/packages/vats/test/bootstrapTests/test-vaults-integration.js b/packages/vats/test/bootstrapTests/test-vaults-integration.js index 8a21dccc46b..7f04db775f8 100644 --- a/packages/vats/test/bootstrapTests/test-vaults-integration.js +++ b/packages/vats/test/bootstrapTests/test-vaults-integration.js @@ -75,8 +75,9 @@ test.after.always(t => { test('metrics path', async t => { const { EV } = t.context.runUtils; // example of awaitVatObject - const vaultFactoryKit = - await EV.vat('bootstrap').consumeItem('vaultFactoryKit'); + const vaultFactoryKit = await EV.vat('bootstrap').consumeItem( + 'vaultFactoryKit', + ); const vfTopics = await EV(vaultFactoryKit.publicFacet).getPublicTopics(); const vfMetricsPath = await EV.get(vfTopics.metrics).storagePath; t.is(vfMetricsPath, 'published.vaultFactory.metrics'); From 2b536dc27e838fedd6bbb8d46f3e6a7c7dd7ef5b Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Wed, 3 Jan 2024 15:01:01 -0800 Subject: [PATCH 3/3] chore: reconcile with this branch --- packages/smart-wallet/src/smartWallet.js | 53 ++++++++++++------------ 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/packages/smart-wallet/src/smartWallet.js b/packages/smart-wallet/src/smartWallet.js index a45b3d56829..f38d832a964 100644 --- a/packages/smart-wallet/src/smartWallet.js +++ b/packages/smart-wallet/src/smartWallet.js @@ -1,5 +1,3 @@ -// backported types are out of sync -// @ts-nocheck import { E } from '@endo/far'; import { AmountShape, @@ -137,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, @@ -174,10 +169,10 @@ 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, @@ -196,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(); @@ -440,7 +435,7 @@ export const prepareSmartWallet = (baggage, shared) => { !used || Fail`cannot re-use offer id ${id}`; }, /** - * @param {RemotePurse} purse + * @param {Purse} purse * @param {Amount} balance */ updateBalance(purse, balance) { @@ -479,7 +474,7 @@ export const prepareSmartWallet = (baggage, shared) => { }); }, - /** @type {(purse: ERef) => Promise} */ + /** @type {(purse: ERef) => Promise} */ async watchPurse(purseRef) { const { facets } = this; @@ -518,7 +513,7 @@ export const prepareSmartWallet = (baggage, shared) => { * 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) { @@ -583,8 +578,6 @@ export const prepareSmartWallet = (baggage, shared) => { const { zoe, agoricNames } = shared; const { invitationBrand, invitationIssuer } = shared; - await null; - const invitationFromSpec = makeInvitationsHelper( zoe, agoricNames, @@ -593,26 +586,33 @@ export const prepareSmartWallet = (baggage, shared) => { 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); - const invitationAmount = - E(invitationIssuer).getAmountOf(invitation); - const watcher = makeOfferWatcher( - facets.helper, - facets.deposit, - offerSpec, - address, - invitationAmount, - seat, + watcherPromises.push( + E.when( + E(invitationIssuer).getAmountOf(invitation), + invitationAmount => { + const watcher = makeOfferWatcher( + facets.helper, + facets.deposit, + offerSpec, + address, + invitationAmount, + seat, + ); + return watchOfferOutcomes(watcher, seat); + }, + ), ); - - void watchOfferOutcomes(watcher, seat); trace(`Repaired seat ${seatId} for wallet ${address}`); } + + await Promise.all(watcherPromises); }, /** @param {import('./offers.js').OfferStatus} offerStatus */ @@ -1053,7 +1053,6 @@ export const prepareSmartWallet = (baggage, shared) => { const { invitationPurse } = state; const { helper } = facets; - // @ts-expect-error RemotePurse cast void helper.watchPurse(invitationPurse); }, },