From d3ba4233697e0613895c8c0235d3898f5ad67153 Mon Sep 17 00:00:00 2001 From: Turadg Aleahmad Date: Tue, 6 Sep 2022 09:16:10 -0700 Subject: [PATCH 1/5] ci(eslint): disable no-undef in .ts files --- .eslintrc.cjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index f44535c423a..8b25e541e59 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -92,6 +92,13 @@ module.exports = { '@jessie.js/no-nested-await': 'warn', }, }, + { + files: ['*.ts'], + rules: { + // TS has this covered and eslint gets it wrong + 'no-undef': 'off', + }, + }, { // disable type-aware linting in HTML files: ['*.html'], From bd77eb90fdf3e429860cf792d01bd0c7716e7361 Mon Sep 17 00:00:00 2001 From: Turadg Aleahmad Date: Wed, 7 Sep 2022 12:10:39 -0700 Subject: [PATCH 2/5] docs(zoe): fix link --- packages/zoe/src/zoeService/types.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/zoe/src/zoeService/types.js b/packages/zoe/src/zoeService/types.js index 0c23ffb96b2..0dcd4ab02c9 100644 --- a/packages/zoe/src/zoeService/types.js +++ b/packages/zoe/src/zoeService/types.js @@ -199,7 +199,7 @@ * UserSeat includes queries for the associated offer's current state and an * operation to request that the offer exit, as follows: * - * @link {https://docs.agoric.com/zoe/api/zoe.html#userseat-object} + * @see {@link https://docs.agoric.com/zoe/api/zoe.html#userseat-object}} * @template {object} [OR=unknown] * @typedef {object} UserSeat * @property {() => Promise} getProposal From 7920543c0b98366590ee306b36e2ccfac0aa0d19 Mon Sep 17 00:00:00 2001 From: Turadg Aleahmad Date: Wed, 7 Sep 2022 10:36:04 -0700 Subject: [PATCH 3/5] refactor(vats): wallet config into 'internal' --- packages/cosmic-swingset/package.json | 1 + packages/cosmic-swingset/src/block-manager.js | 3 +-- packages/internal/src/config.js | 27 +++++++++++++++++++ packages/internal/src/index.js | 1 + packages/vats/package.json | 1 + packages/vats/src/bridge-ids.js | 7 ----- packages/vats/src/core/basic-behaviors.js | 5 ++-- packages/vats/src/core/chain-behaviors.js | 3 +-- packages/vats/src/core/startWalletFactory.js | 3 +-- packages/vats/src/vat-bank.js | 3 +-- packages/wallet/contract/package.json | 1 + packages/wallet/contract/src/walletFactory.js | 4 +-- 12 files changed, 39 insertions(+), 20 deletions(-) create mode 100644 packages/internal/src/config.js delete mode 100644 packages/vats/src/bridge-ids.js diff --git a/packages/cosmic-swingset/package.json b/packages/cosmic-swingset/package.json index d2c533730ee..d66a89c075c 100644 --- a/packages/cosmic-swingset/package.json +++ b/packages/cosmic-swingset/package.json @@ -25,6 +25,7 @@ "@agoric/assert": "^0.4.0", "@agoric/cosmos": "^0.30.0", "@agoric/deploy-script-support": "^0.9.0", + "@agoric/internal": "^0.1.0", "@agoric/nat": "^4.1.0", "@agoric/store": "^0.7.2", "@agoric/swing-store": "^0.7.0", diff --git a/packages/cosmic-swingset/src/block-manager.js b/packages/cosmic-swingset/src/block-manager.js index 8236e109fcc..a828b2b6f88 100644 --- a/packages/cosmic-swingset/src/block-manager.js +++ b/packages/cosmic-swingset/src/block-manager.js @@ -3,8 +3,7 @@ import anylogger from 'anylogger'; import { assert, details as X } from '@agoric/assert'; - -import * as BRIDGE_ID from '@agoric/vats/src/bridge-ids.js'; +import { BridgeId as BRIDGE_ID } from '@agoric/internal'; import * as ActionType from './action-types.js'; import { parseParams } from './params.js'; diff --git a/packages/internal/src/config.js b/packages/internal/src/config.js new file mode 100644 index 00000000000..c3923390a10 --- /dev/null +++ b/packages/internal/src/config.js @@ -0,0 +1,27 @@ +// @ts-check +/** @file + * + * Some of this config info may make more sense in a particular package. However + * due to the maxNodeModuleJsDepth hack and our general lax dependency graph, + * sometimes rational placements cause type resolution errors. + * + * So as a work-around some constants that need access from more than one package are placed here. + */ + +/** + * Event source ids used by the bridge device. + */ +export const BridgeId = { + BANK: 'bank', + CORE: 'core', + DIBC: 'dibc', + STORAGE: 'storage', + PROVISION: 'provision', + WALLET: 'wallet', +}; +harden(BridgeId); + +export const WalletName = { + depositFacet: 'depositFacet', +}; +harden(WalletName); diff --git a/packages/internal/src/index.js b/packages/internal/src/index.js index cdcd140a2a6..85292d0752d 100644 --- a/packages/internal/src/index.js +++ b/packages/internal/src/index.js @@ -1,3 +1,4 @@ // @ts-check export * from './utils.js'; +export * from './config.js'; diff --git a/packages/vats/package.json b/packages/vats/package.json index f5a0b89a445..7e5da962fba 100644 --- a/packages/vats/package.json +++ b/packages/vats/package.json @@ -46,6 +46,7 @@ }, "devDependencies": { "@agoric/deploy-script-support": "^0.9.0", + "@agoric/smart-wallet": "^0.2.0", "@endo/bundle-source": "^2.2.6", "@endo/captp": "^2.0.13", "ava": "^4.3.1", diff --git a/packages/vats/src/bridge-ids.js b/packages/vats/src/bridge-ids.js deleted file mode 100644 index b86a362cfc5..00000000000 --- a/packages/vats/src/bridge-ids.js +++ /dev/null @@ -1,7 +0,0 @@ -// Event source ids used by the bridge device. -export const BANK = 'bank'; -export const CORE = 'core'; -export const DIBC = 'dibc'; -export const STORAGE = 'storage'; -export const PROVISION = 'provision'; -export const WALLET = 'wallet'; diff --git a/packages/vats/src/core/basic-behaviors.js b/packages/vats/src/core/basic-behaviors.js index 402491a4628..2e28111be3f 100644 --- a/packages/vats/src/core/basic-behaviors.js +++ b/packages/vats/src/core/basic-behaviors.js @@ -5,8 +5,7 @@ import { Nat } from '@agoric/nat'; import { makeScalarMapStore } from '@agoric/store'; import { provideLazy } from '@agoric/store/src/stores/store-utils.js'; import { E, Far } from '@endo/far'; - -import { deeplyFulfilledObject } from '@agoric/internal'; +import { deeplyFulfilledObject, WalletName } from '@agoric/internal'; import { makeStorageNodeChild } from '../lib-chainStorage.js'; import { makeNameHubKit } from '../nameHub.js'; import { feeIssuerConfig } from './utils.js'; @@ -190,7 +189,7 @@ export const makeMyAddressNameAdmin = (namesByAddressAdmin, address) => { getMyAddress: () => address, }); // reserve space for deposit facet - myAddressNameAdmin.reserve('depositFacet'); + myAddressNameAdmin.reserve(WalletName.depositFacet); // Register it with the namesByAddress hub. namesByAddressAdmin.update(address, myAddressNameHub, myAddressNameAdmin); diff --git a/packages/vats/src/core/chain-behaviors.js b/packages/vats/src/core/chain-behaviors.js index 59b9f20042c..6dbe8fd4604 100644 --- a/packages/vats/src/core/chain-behaviors.js +++ b/packages/vats/src/core/chain-behaviors.js @@ -16,9 +16,8 @@ import { } from '@agoric/swingset-vat/src/vats/network/index.js'; import { importBundle } from '@endo/import-bundle'; import * as Collect from '@agoric/inter-protocol/src/collect.js'; - +import { BridgeId as BRIDGE_ID } from '@agoric/internal'; import { makeBridgeManager as makeBridgeManagerKit } from '../bridge.js'; -import * as BRIDGE_ID from '../bridge-ids.js'; import * as STORAGE_PATH from '../chain-storage-paths.js'; import { agoricNamesReserved, callProperties, extractPowers } from './utils.js'; diff --git a/packages/vats/src/core/startWalletFactory.js b/packages/vats/src/core/startWalletFactory.js index 13d7a401daa..f49d262e02d 100644 --- a/packages/vats/src/core/startWalletFactory.js +++ b/packages/vats/src/core/startWalletFactory.js @@ -1,8 +1,7 @@ // @ts-check import { E, Far } from '@endo/far'; import { deeplyFulfilled } from '@endo/marshal'; - -import * as BRIDGE_ID from '../bridge-ids.js'; +import { BridgeId as BRIDGE_ID } from '@agoric/internal'; import { makeStorageNodeChild } from '../lib-chainStorage.js'; import { makeMyAddressNameAdmin, PowerFlags } from './basic-behaviors.js'; diff --git a/packages/vats/src/vat-bank.js b/packages/vats/src/vat-bank.js index 5d94a50d508..11b395ed708 100644 --- a/packages/vats/src/vat-bank.js +++ b/packages/vats/src/vat-bank.js @@ -4,9 +4,8 @@ import { AmountMath, AssetKind } from '@agoric/ertp'; import { E, Far } from '@endo/far'; import { makeNotifierKit, makeSubscriptionKit } from '@agoric/notifier'; import { makeStore, makeWeakStore } from '@agoric/store'; - +import { BridgeId as BRIDGE_ID } from '@agoric/internal'; import { makeVirtualPurse } from './virtual-purse.js'; -import * as BRIDGE_ID from './bridge-ids.js'; import '@agoric/notifier/exported.js'; diff --git a/packages/wallet/contract/package.json b/packages/wallet/contract/package.json index a865c471c5e..74f95591b8d 100644 --- a/packages/wallet/contract/package.json +++ b/packages/wallet/contract/package.json @@ -24,6 +24,7 @@ "dependencies": { "@agoric/wallet-backend": "0.12.1", "@agoric/deploy-script-support": "^0.9.0", + "@agoric/internal": "^0.1.0", "@agoric/store": "^0.7.2", "@agoric/vat-data": "^0.3.1", "@agoric/vats": "^0.10.0", diff --git a/packages/wallet/contract/src/walletFactory.js b/packages/wallet/contract/src/walletFactory.js index 715ec7ac090..96036dcde82 100644 --- a/packages/wallet/contract/src/walletFactory.js +++ b/packages/wallet/contract/src/walletFactory.js @@ -10,7 +10,7 @@ import '@agoric/zoe/exported.js'; import { makeAtomicProvider } from '@agoric/store/src/stores/store-utils.js'; import { makeScalarBigMapStore } from '@agoric/vat-data'; -import * as BRIDGE_ID from '@agoric/vats/src/bridge-ids.js'; +import { BridgeId } from '@agoric/internal'; import { E, Far } from '@endo/far'; import { makeSmartWallet } from './smartWallet.js'; @@ -77,7 +77,7 @@ export const start = async (zcf, privateArgs) => { // NOTE: both `MsgWalletAction` and `MsgWalletSpendAction` arrive as BRIDGE_ID.WALLET // by way of makeBlockManager() in cosmic-swingset/src/block-manager.js await (bridgeManager && - E(bridgeManager).register(BRIDGE_ID.WALLET, handleWalletAction)); + E(bridgeManager).register(BridgeId.WALLET, handleWalletAction)); const shared = { agoricNames, From eb818371e3a57500281c7dc1b341fe64c1afbb3e Mon Sep 17 00:00:00 2001 From: Turadg Aleahmad Date: Wed, 7 Sep 2022 11:51:40 -0700 Subject: [PATCH 4/5] chore(ertp): parameterize makeEmptyPurse type --- packages/ERTP/src/types.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ERTP/src/types.js b/packages/ERTP/src/types.js index a3a5c7de934..57520d8039e 100644 --- a/packages/ERTP/src/types.js +++ b/packages/ERTP/src/types.js @@ -243,7 +243,7 @@ * MathHelpers used by this Issuer. * @property {() => DisplayInfo} getDisplayInfo Give information to UI * on how to display amounts for this issuer. - * @property {() => Purse} makeEmptyPurse Make an empty purse of this + * @property {() => Purse} makeEmptyPurse Make an empty purse of this * brand. * @property {IssuerIsLive} isLive * @property {IssuerGetAmountOf} getAmountOf From 708972f1f531c9ea5e346f833c6d253efe80f837 Mon Sep 17 00:00:00 2001 From: Turadg Aleahmad Date: Sat, 3 Sep 2022 12:17:09 -0700 Subject: [PATCH 5/5] feat: new Smart Wallet --- packages/smart-wallet/README.md | 70 +++- packages/smart-wallet/package.json | 5 +- packages/smart-wallet/src/AttackersGuide.md | 49 +++ packages/smart-wallet/src/invitations.js | 102 ++++++ packages/smart-wallet/src/offers.js | 168 +++++++++ packages/smart-wallet/src/payments.js | 96 +++++ packages/smart-wallet/src/smartWallet.js | 333 ++++++++++++++++++ packages/smart-wallet/src/types.d.ts | 78 ++++ packages/smart-wallet/src/utils.js | 46 +++ packages/smart-wallet/src/walletFactory.js | 119 +++++++ packages/smart-wallet/test/contexts.js | 96 +++++ packages/smart-wallet/test/devices.js | 23 ++ packages/smart-wallet/test/supports.js | 218 ++++++++++++ .../smart-wallet/test/test-amm-integration.js | 8 + .../smart-wallet/test/test-psm-integration.js | 268 ++++++++++++++ packages/smart-wallet/test/test-stub.js | 3 - .../smart-wallet/test/test-walletFactory.js | 111 ++++++ packages/vats/src/core/startWalletFactory.js | 2 + 18 files changed, 1788 insertions(+), 7 deletions(-) create mode 100644 packages/smart-wallet/src/AttackersGuide.md create mode 100644 packages/smart-wallet/src/invitations.js create mode 100644 packages/smart-wallet/src/offers.js create mode 100644 packages/smart-wallet/src/payments.js create mode 100644 packages/smart-wallet/src/smartWallet.js create mode 100644 packages/smart-wallet/src/types.d.ts create mode 100644 packages/smart-wallet/src/utils.js create mode 100644 packages/smart-wallet/src/walletFactory.js create mode 100644 packages/smart-wallet/test/contexts.js create mode 100644 packages/smart-wallet/test/devices.js create mode 100644 packages/smart-wallet/test/supports.js create mode 100644 packages/smart-wallet/test/test-amm-integration.js create mode 100644 packages/smart-wallet/test/test-psm-integration.js delete mode 100644 packages/smart-wallet/test/test-stub.js create mode 100644 packages/smart-wallet/test/test-walletFactory.js diff --git a/packages/smart-wallet/README.md b/packages/smart-wallet/README.md index b3d5642fe91..db6a0a858cd 100644 --- a/packages/smart-wallet/README.md +++ b/packages/smart-wallet/README.md @@ -1,5 +1,69 @@ -# Smart Wallet contract +# Smart Wallet -Future home of the Smart Wallet contract. +The `walletFactory` contract provisions and manages smart wallets. -For the legacy contract, see [@agoric/legacy-smart-wallet](../wallet/contract/README.md). +## Usage + +There can be zero or one wallets per Cosmos address. + +1. Generate an address (off-chain) +2. Provision an account using that address, which causes a Bank to get created + ??? What happens if you try to provision again using the same address? It's a Cosmos level transaction; maybe that fails. +3. Create a Wallet using the Bank (it includes the implementation of Virtual Purses so when you getAmount it goes down to the Golang layer) + ??? What happens if you try to create another wallet using that bank? + +1 Address : 0/1 Bank +1 Address : 1 `myAddressNamesAdmin` +1 Bank : 0/1 Wallet + +By design there's a 1:1 across all four. + +`namesByAddress` and `board` are shared by everybody. + +`myAddressNamesAdmin` is from the account you provision. + +## Design + +See the [Attackers Guide](src/AttackersGuide.md) for security requirements. + +Product requirements: + +- provision a wallet +- execute offers using the wallet +- deposit payments into the wallet's purses +- notification of state changes + +Each of the above has to work over two channels: + +- ocap for JS in vats holding object references (e.g. factory or wallet) +- Cosmos signed messages + +Non-requirements: + +- Multiple purses per brand ([#6126](https://github.com/Agoric/agoric-sdk/issues/6126)). When this is a requirement we'll need some way to specify in offer execution which purses to take funds from. For UX we shouldn't require that specificity unless there are multiple purses. When there are, lack of specifier could throw or we could have a "default" purse for each brand. + +# Testing + +There are no automated tests yet verifying the smart wallet running on chain. Here are procedures you can use instead. + +## Notifiers + +``` +# tab 1 (chain) +cd packages/cosmic-swingset/ +make scenario2-setup scenario2-run-chain +# starts bare chain, don’t need AMM + +# tab 2 (client server) +cd packages/cosmic-swingset/ +make scenario2-run-client +# confirm no errors in logs + +# tab 3 (interactive) +agoric open --repl +# confirm in browser that `home.wallet` and `home.smartWallet` exist +agd query vstorage keys 'published.wallet' +# confirm it has a key like `published.wallet.agoric1nqxg4pye30n3trct0hf7dclcwfxz8au84hr3ht` +agoric follow :published.wallet.agoric1nqxg4pye30n3trct0hf7dclcwfxz8au84hr3ht +# confirm it has JSON data +``` diff --git a/packages/smart-wallet/package.json b/packages/smart-wallet/package.json index 40ab4e8853b..bfba44bb868 100644 --- a/packages/smart-wallet/package.json +++ b/packages/smart-wallet/package.json @@ -14,8 +14,12 @@ }, "devDependencies": { "ava": "^4.3.1", + "@agoric/cosmic-swingset": "^0.37.0", + "@agoric/cosmic-proto": "^0.1.0", "@agoric/inter-protocol": "^0.11.0", "@agoric/swingset-vat": "^0.28.0", + "@agoric/wallet-backend": "^0.12.1", + "@agoric/vats": "^0.10.0", "@agoric/zoe": "^0.24.0", "@endo/captp": "^2.0.13" }, @@ -27,7 +31,6 @@ "@agoric/notifier": "^0.4.0", "@agoric/store": "^0.7.2", "@agoric/vat-data": "^0.3.1", - "@agoric/vats": "^0.10.0", "@endo/far": "^0.2.9" }, "keywords": [], diff --git a/packages/smart-wallet/src/AttackersGuide.md b/packages/smart-wallet/src/AttackersGuide.md new file mode 100644 index 00000000000..0f9e70f785b --- /dev/null +++ b/packages/smart-wallet/src/AttackersGuide.md @@ -0,0 +1,49 @@ +# An Attacker's guide to Smart Wallets + +This is an incomplete list of potential weak points that an attacker might want to focus +on when looking for ways to violate the integrity of Smart Wallets. It's here to help +defenders, as "attacker's mindset" is a good way to focus attention for the defender. The +list should correspond pretty closely to the set of assurances that Smart Wallest aims +to support. + +## Factory + +This is the contract instance. It is responsible for, + +- provisioning wallets +- passing them messages over the bridge +- maintaining state through upgrade + +## Individual Wallet + +The design assumes that assets pass only in these ways: + +1. IN on the `deposit` facet +2. IN by proceeds of an offer (`getPayouts()`) +3. OUT by making an offer + +## Types of attack + +### Theft + +The wallet instances rest on the ocap model + +### Destruction + +No matter what message the contract must not drop any assets into the void. Pay special attention to the time between an offer withdrawing payments from the wallet's purse(s) and the payouts being deposited (or it being refunded for wants not satisfied). + +If the attacker could force a fatal error somewhere (perhaps in their own wallet) it would terminate the vat, which holds the factory and all the wallets. Are the offer processing states robust to termination? For example, what happens if you withdraw $10 from your purse to a payment for your offer and the vat dies before you take payouts? + +### Denial of service + +#### Resource exhaustion + +The factory provides wallets and to do so much keep a hold of all wallets +produced. To mitigate, these shouldn't be held in RAM. By design they are in a +ScaleBigMapStore backed by disk. + +The wallet object holds many types of state. The state must not grow monotonically over use or an attacker could grow the cost of holding the wallet in RAM to so much that it kills the vat. + +### Deadlock + +If an attacker can craft a message that leads some part of the code to deadlock or wait indefinitely, this could prevent use. diff --git a/packages/smart-wallet/src/invitations.js b/packages/smart-wallet/src/invitations.js new file mode 100644 index 00000000000..e736ba6f80c --- /dev/null +++ b/packages/smart-wallet/src/invitations.js @@ -0,0 +1,102 @@ +// @ts-check +import { AmountMath } from '@agoric/ertp'; +import { E } from '@endo/far'; + +/** + * Supports three cases + * 1. source is a contract (in which case this takes an Instance to look up in zoe) + * 2. the invitation is already in your Zoe "invitation" purse so we need to query it + * - use the find/query invitation by kvs thing + * 3. continuing invitation in which the offer result from a previous invitation had an `invitationMakers` property + * + * @typedef {ContractInvitationSpec | PurseInvitationSpec | ContinuingInvitationSpec} InvitationSpec + */ +/** + * @typedef {{ + * source: 'contract', + * instance: Instance, + * publicInvitationMaker: string, + * invitationArgs?: any[], + * }} ContractInvitationSpec + * @typedef {{ + * source: 'purse', + * instance: Instance, + * description: string, + * }} PurseInvitationSpec + * @typedef {{ + * source: 'continuing', + * previousOffer: number, + * invitationMakerName: string, + * invitationArgs?: any[], + * }} ContinuingInvitationSpec + */ + +/** + * @typedef {Pick} InvitationsPurseQuery + */ + +/** + * + * @param {ERef} zoe + * @param {Brand<'set'>} invitationBrand + * @param {Purse<'set'>} invitationsPurse + * @param {(fromOfferId: number) => import('./types').RemoteInvitationMakers} getInvitationContinuation + */ +export const makeInvitationsHelper = ( + zoe, + invitationBrand, + invitationsPurse, + getInvitationContinuation, +) => { + // TODO(6062) validate params with patterns + const invitationGetters = /** @type {const} */ ({ + /** @type {(spec: ContractInvitationSpec) => Promise} */ + contract(spec) { + const { instance, publicInvitationMaker, invitationArgs = [] } = spec; + const pf = E(zoe).getPublicFacet(instance); + return E(pf)[publicInvitationMaker](...invitationArgs); + }, + /** @type {(spec: PurseInvitationSpec) => Promise} */ + async purse(spec) { + const { instance, description } = spec; + assert(instance && description, 'missing instance or description'); + /** @type {Amount<'set'>} */ + const purseAmount = await E(invitationsPurse).getCurrentAmount(); + const match = AmountMath.getValue(invitationBrand, purseAmount).find( + details => + details.description === description && details.instance === instance, + ); + assert(match, `no matching purse for ${{ instance, description }}`); + const toWithDraw = AmountMath.make(invitationBrand, harden([match])); + console.log('.... ', { toWithDraw }); + + return E(invitationsPurse).withdraw(toWithDraw); + }, + /** @type {(spec: ContinuingInvitationSpec) => Promise} */ + continuing(spec) { + console.log('making continuing invitation', spec); + const { previousOffer, invitationArgs = [], invitationMakerName } = spec; + const makers = getInvitationContinuation(previousOffer); + assert( + makers, + `invalid value stored for previous offer ${previousOffer}`, + ); + return E(makers)[invitationMakerName](...invitationArgs); + }, + }); + /** @type {(spec: InvitationSpec) => ERef} */ + const invitationFromSpec = spec => { + switch (spec.source) { + case 'contract': + return invitationGetters.contract(spec); + case 'purse': + return invitationGetters.purse(spec); + case 'continuing': + return invitationGetters.continuing(spec); + default: + throw new Error('unrecognize invitation source'); + } + }; + return invitationFromSpec; +}; +harden(makeInvitationsHelper); diff --git a/packages/smart-wallet/src/offers.js b/packages/smart-wallet/src/offers.js new file mode 100644 index 00000000000..3bfa5545b40 --- /dev/null +++ b/packages/smart-wallet/src/offers.js @@ -0,0 +1,168 @@ +// @ts-check + +import { E, Far, passStyleOf } from '@endo/far'; +import { makePaymentsHelper } from './payments.js'; + +/** + * @typedef {{ + * id: number, + * invitationSpec: import('./invitations').InvitationSpec, + * proposal: Proposal, + * offerArgs?: Record + * }} OfferSpec + */ + +/** Value for "result" field when the result can't be published */ +export const UNPUBLISHED_RESULT = 'UNPUBLISHED'; + +/** + * @typedef {import('./offers.js').OfferSpec & { + * error?: string, + * numWantsSatisfied?: number + * result?: unknown | typeof UNPUBLISHED_RESULT, + * payouts?: AmountKeywordRecord, + * }} OfferStatus + */ + +/* eslint-disable jsdoc/check-param-names -- bug(?) with nested objects */ +/** + * @param {object} opts + * @param {ERef} opts.zoe + * @param {object} opts.powers + * @param {import('./types').Cell} opts.powers.lastOfferId + * @param {(spec: import('./invitations').InvitationSpec) => ERef} opts.powers.invitationFromSpec + * @param {(brand: Brand) => import('./types').RemotePurse} opts.powers.purseForBrand + * @param {(status: OfferStatus) => void} opts.onStatusChange + * @param {(offerId: number, continuation: import('./types').RemoteInvitationMakers) => void} opts.onNewContinuingOffer + */ +export const makeOffersFacet = ({ + zoe, + powers, + onStatusChange, + onNewContinuingOffer, +}) => { + const { invitationFromSpec, lastOfferId, purseForBrand } = powers; + + return Far('offers facet', { + /** + * Take an offer description provided in capData, augment it with payments and call zoe.offer() + * + * @param {OfferSpec} offerSpec + * @returns {Promise} when the offer has been sent to Zoe; payouts go into this wallet's purses + * @throws if any parts of the offer can be determined synchronously to be invalid + */ + executeOffer: async offerSpec => { + console.log('executeOffer', { offerSpec }); + const paymentsManager = makePaymentsHelper(purseForBrand); + + /** @type {OfferStatus} */ + let status = { + ...offerSpec, + }; + /** @param {Partial} changes */ + const updateStatus = changes => { + status = { ...status, ...changes }; + onStatusChange(status); + }; + /** + * Notify user and attempt to recover + * + * @param {Error} err + */ + const handleError = err => { + console.error('OFFER ERROR:', err); + updateStatus({ error: err.toString() }); + paymentsManager.tryReclaimingWithdrawnPayments().then(result => { + if (result) { + updateStatus({ result }); + } + }); + }; + + try { + // 1. Prepare values and validate synchronously. + const { id, invitationSpec, proposal, offerArgs } = offerSpec; + // consume id immediately so that all errors can pertain to a particular offer id. + // This also serves to validate the new id. + lastOfferId.set(id); + + assert(invitationSpec, 'offer missing invitationSpec'); + assert(proposal, 'offer missing proposal'); + + const invitation = invitationFromSpec(invitationSpec); + + const paymentKeywordRecord = proposal?.give + ? paymentsManager.withdrawGive(proposal.give) + : undefined; + + // 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 + const seatRef = await E(zoe).offer( + invitation, + proposal, + paymentKeywordRecord, + offerArgs, + ); + // ??? should we notify of being seated? + + // publish 'result' + E.when( + E(seatRef).getOfferResult(), + result => { + const passStyle = passStyleOf(result); + console.log('offerResult', passStyle, result); + // someday can we get TS to type narrow based on the passStyleOf result match? + switch (passStyle) { + case 'copyRecord': + if ('invitationMakers' in result) { + // save for continuing invitation offer + onNewContinuingOffer(id, result.invitationMakers); + } + // ??? are all copyRecord types valid to publish? + updateStatus({ result }); + break; + default: + // drop the result + updateStatus({ result: UNPUBLISHED_RESULT }); + } + }, + handleError, + ); + + // publish 'numWantsSatisfied' + E.when(E(seatRef).numWantsSatisfied(), numSatisfied => { + if (numSatisfied === 0) { + updateStatus({ numWantsSatisfied: 0 }); + } + updateStatus({ + numWantsSatisfied: numSatisfied, + }); + }); + + // publish 'payouts' + // This will block until all payouts succeed, but user will be updated + // as each payout will trigger its corresponding purse notifier. + E.when( + E(seatRef).getPayouts(), + payouts => + paymentsManager.depositPayouts(payouts).then(amounts => { + updateStatus({ payouts: amounts }); + }), + handleError, + ); + } catch (err) { + handleError(err); + } + }, + + /** + * Contracts can use this to generate a valid (monotonic) offer ID by incrementing. + * In most cases it will be faster to get this from RPC query. + */ + getLastOfferId: lastOfferId.get, + }); +}; +harden(makeOffersFacet); diff --git a/packages/smart-wallet/src/payments.js b/packages/smart-wallet/src/payments.js new file mode 100644 index 00000000000..3fbfd78dfe1 --- /dev/null +++ b/packages/smart-wallet/src/payments.js @@ -0,0 +1,96 @@ +// @ts-check + +import { deeplyFulfilledObject, objectMap } from '@agoric/internal'; +import { E } from '@endo/far'; + +/** + * Used in an offer execution to manage payments state safely. + * + * @param {(brand: Brand) => import('./types').RemotePurse} purseForBrand + */ +export const makePaymentsHelper = purseForBrand => { + /** @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) { + assert( + !keywordPaymentPromises, + 'withdrawPayments can be called once per helper', + ); + keywordPaymentPromises = objectMap(give, amount => { + const purse = purseForBrand(amount.brand); + return E(purse) + .withdraw(amount) + .then(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); + } + }), + ); + }, + + // TODO(PS0?) when there's not a purse for a brand, hold the payout and wait for a purse to deposit it into + // Cheaper alternative: before offer validate we have issuers for all the 'wants' so the results can be put into purses. + /** + * + * @param {PaymentPKeywordRecord} payouts + * @returns {Promise} + */ + async depositPayouts(payouts) { + /** @type {PaymentKeywordRecord} */ + // @ts-expect-error ??? + const paymentKeywordRecord = await deeplyFulfilledObject(payouts); + /** Record> */ + const amountPKeywordRecord = objectMap(paymentKeywordRecord, payment => + E(purseForBrand(payment.getAllegedBrand())).deposit(payment), + ); + return deeplyFulfilledObject(amountPKeywordRecord); + }, + }; +}; +harden(makePaymentsHelper); diff --git a/packages/smart-wallet/src/smartWallet.js b/packages/smart-wallet/src/smartWallet.js new file mode 100644 index 00000000000..05de5719a74 --- /dev/null +++ b/packages/smart-wallet/src/smartWallet.js @@ -0,0 +1,333 @@ +// @ts-check +import { isNat } from '@agoric/nat'; +import { + makeStoredPublishKit, + observeIteration, + observeNotifier, +} from '@agoric/notifier'; +import { makeScalarMapStore } from '@agoric/store'; +import { makeScalarBigMapStore } from '@agoric/vat-data'; +import { E, Far } from '@endo/far'; +import { makeInvitationsHelper } from './invitations.js'; +import { makeOffersFacet } from './offers.js'; + +const { details: X, quote: q } = assert; + +/** + * @file Smart wallet module + * + * @see {@link ../README.md}} + */ + +// One method yet but structured to support more. For example, +// maybe suggestIssuer for https://github.com/Agoric/agoric-sdk/issues/6132 +// setting petnames and adding brands for https://github.com/Agoric/agoric-sdk/issues/6126 +/** + * @typedef {{ + * method: 'executeOffer' + * offer: import('./offers.js').OfferSpec, + * }} BridgeAction + */ + +/** + * @typedef {{ updated: 'offerStatus', status: import('./offers.js').OfferStatus } | + * { updated: 'balance'; currentAmount: Amount } | + * { updated: 'brand', descriptor: BrandDescriptor } + * } UpdateRecord Record of an update to the state of this wallet. + * + * Client is responsible for coalescing updates into a current state. See `coalesceUpdates` utility. + * + * The reason for this burden on the client is that transferring the full state is untenable + * (because it would grow monotonically). + * + * `balance` update supports forward-compatibility for more than one purse per + * brand. An additional key will be needed to disambiguate. For now the brand in + * the amount suffices. + */ + +/** + * @typedef {{ + * brand: Brand, + * displayInfo: DisplayInfo, + * issuer: ERef, + * petname: import('./types').Petname + * }} BrandDescriptor + * For use by clients to describe brands to users. Includes `displayInfo` to save a remote call. + */ + +// imports +/** @typedef {import('./types').RemotePurse} RemotePurse */ + +/** + * + * @param {{ + * address: string, + * bank: ERef, + * }} unique + * @param {{ + * agoricNames: ERef, + * board: ERef, + * invitationIssuer: ERef>, + * invitationBrand: Brand<'set'>, + * storageNode: ERef, + * zoe: ERef, + * }} shared + */ +export const makeSmartWallet = async ( + { address, bank }, + { board, invitationBrand, invitationIssuer, storageNode, zoe }, +) => { + assert.typeof(address, 'string', 'invalid address'); + assert(bank, 'missing bank'); + assert(invitationIssuer, 'missing invitationIssuer'); + assert(invitationBrand, 'missing invitationBrand'); + assert(storageNode, 'missing storageNode'); + // cache + const [invitationPurse, marshaller] = await Promise.all([ + E(invitationIssuer).makeEmptyPurse(), + E(board).getReadonlyMarshaller(), + ]); + + // #region STATE + + // - brandPurses is precious and closely held. defined as late as possible to reduce its scope. + // - offerToInvitationMakers is precious and closely held. + // - lastOfferId is precious but not closely held + // - brandDescriptors will be precious. Currently it includes invitation brand and what we've received from the bank manager. + // - purseBalances is a cache of what we've received from purses. Held so we can publish all balances on change. + + /** + * To ensure every offer ID is unique we require that each is a number greater + * than has ever been used. This high water mark is sufficient to track that. + * + * @type {number} + */ + let lastOfferId = 0; + + /** + * Invitation makers yielded by offer results + * + * @type {MapStore} + */ + const offerToInvitationMakers = makeScalarBigMapStore('invitation makers', { + durable: true, + }); + + /** @type {MapStore} */ + const brandDescriptors = makeScalarMapStore(); + + /** + * What purses have reported on construction and by getCurrentAmountNotifier updates. + * + * @type {MapStore} + */ + const purseBalances = makeScalarMapStore(); + + // #endregion + + // #region publishing + // NB: state size must not grow monotonically + // This is the node that UIs subscribe to for everything they need. + // e.g. agoric follow :published.wallet.agoric1nqxg4pye30n3trct0hf7dclcwfxz8au84hr3ht + const myWalletStorageNode = E(storageNode).makeChildNode(address); + + /** @type {StoredPublishKit} */ + const updatePublishKit = makeStoredPublishKit( + myWalletStorageNode, + marshaller, + ); + + /** + * @param {RemotePurse} purse + * @param {Amount} balance + * @param {'init'} [init] + */ + const updateBalance = (purse, balance, init) => { + if (init) { + purseBalances.init(purse, balance); + } else { + purseBalances.set(purse, balance); + } + updatePublishKit.publisher.publish({ + updated: 'balance', + currentAmount: balance, + }); + }; + + // #endregion + + // #region issuer management + /** + * Private purses. This assumes one purse per brand, which will be valid in MN-1 but not always. + * + * @type {MapStore} + */ + const brandPurses = makeScalarBigMapStore('brand purses', { durable: true }); + + /** @type { (desc: Omit, purse: RemotePurse) => Promise} */ + const addBrand = async (desc, purseRef) => { + // assert haven't received this issuer before. + const descriptorsHas = brandDescriptors.has(desc.brand); + const pursesHas = brandPurses.has(desc.brand); + assert( + !(descriptorsHas && pursesHas), + 'repeated brand from bank asset subscription', + ); + assert( + !(descriptorsHas || pursesHas), + 'corrupted state; one store has brand already', + ); + + const [purse, displayInfo] = await Promise.all([ + purseRef, + E(desc.brand).getDisplayInfo(), + ]); + + // save all five of these in a collection (indexed by brand?) so that when + // it's time to take an offer description you know where to get the + // relevant purse. when it's time to make an offer, you know how to make + // payments. REMEMBER when doing that, need to handle every exception to + // put the money back in the purse if anything fails. + const descriptor = { ...desc, displayInfo }; + brandDescriptors.init(desc.brand, descriptor); + brandPurses.init(desc.brand, purse); + + // publish purse's balance and changes + E.when( + E(purse).getCurrentAmount(), + balance => updateBalance(purse, balance, 'init'), + err => + console.error(address, 'initial purse balance publish failed', err), + ); + observeNotifier(E(purse).getCurrentAmountNotifier(), { + updateState(balance) { + updateBalance(purse, balance); + }, + fail(reason) { + console.error(address, `failed updateState observer`, reason); + }, + }); + + updatePublishKit.publisher.publish({ updated: 'brand', descriptor }); + }; + + // Ensure a purse for each issuer + addBrand( + { + brand: invitationBrand, + issuer: invitationIssuer, + petname: 'invitations', + }, + // @ts-expect-error cast to RemotePurse + /** @type {RemotePurse} */ (invitationPurse), + ); + // watch the bank for new issuers to make purses out of + observeIteration(E(bank).getAssetSubscription(), { + async updateState(desc) { + /** @type {RemotePurse} */ + // @ts-expect-error cast to RemotePurse + const purse = E(bank).getPurse(desc.brand); + await addBrand( + { + brand: desc.brand, + issuer: desc.issuer, + petname: desc.proposedName, + }, + purse, + ); + }, + }); + // #endregion + + /** + * Similar to {DepositFacet} but async because it has to look up the purse. + */ + const depositFacet = Far('smart wallet deposit facet', { + // TODO(PS0) decide whether to match canonical `DepositFacet'. it would have to take a local Payment. + /** + * Put the assets from the payment into the appropriate purse + * + * @param {ERef} paymentE + * @param {Brand} [paymentBrand] when provided saves remote lookup. Must match the payment's brand. + * @returns {Promise} + * @throws if the purse doesn't exist + * NB: the previous smart wallet contract would try again each time there's a new issuer. + * This version does not: 1) for expedience, 2: to avoid resource exhaustion vulnerability. + */ + receive: async (paymentE, paymentBrand) => { + const brand = await (paymentBrand || E(paymentE).getAllegedBrand()); + const purse = brandPurses.get(brand); + + return E.when(paymentE, payment => E(purse).deposit(payment)); + }, + }); + + const offersFacet = makeOffersFacet({ + zoe, + powers: { + invitationFromSpec: makeInvitationsHelper( + zoe, + invitationBrand, + invitationPurse, + offerToInvitationMakers.get, + ), + purseForBrand: brandPurses.get, + lastOfferId: { + get: () => lastOfferId, + set(id) { + assert(isNat(id), 'offer id must be a positive number'); + assert( + id > lastOfferId, + 'offer id must be greater than all previous', + ); + lastOfferId = id; + }, + }, + }, + onStatusChange: offerStatus => + updatePublishKit.publisher.publish({ + updated: 'offerStatus', + status: offerStatus, + }), + onNewContinuingOffer: (offerId, invitationMakers) => + offerToInvitationMakers.init(offerId, invitationMakers), + }); + + /** + * + * @param {import('./types').WalletCapData} actionCapData + * @param {boolean} [canSpend=false] + */ + const handleBridgeAction = (actionCapData, canSpend = false) => { + assert(actionCapData.body && actionCapData.slots, 'invalid capdata'); + assert(!canSpend, 'spending not yet supported'); + return E.when( + E(marshaller).unserialize(actionCapData), + /** @param {BridgeAction} action */ + action => { + switch (action.method) { + case 'executeOffer': + return E(offersFacet).executeOffer(action.offer); + default: + assert.fail(X`invalid handle bridge action ${q(action)}`); + } + }, + ); + }; + + /** + * Holders of this object: + * - vat (transitively from holding the wallet factory) + * - wallet-ui (which has key material; dapps use wallet-ui to propose actions) + */ + return Far('SmartWallet', { + handleBridgeAction, + getDepositFacet: () => depositFacet, + getOffersFacet: () => offersFacet, + + getUpdatesSubscriber: () => updatePublishKit.subscriber, + }); +}; +harden(makeSmartWallet); + +/** @typedef {Awaited>} SmartWallet */ diff --git a/packages/smart-wallet/src/types.d.ts b/packages/smart-wallet/src/types.d.ts new file mode 100644 index 00000000000..e39da581e28 --- /dev/null +++ b/packages/smart-wallet/src/types.d.ts @@ -0,0 +1,78 @@ +/** + * @file Some types for smart-wallet contract + * + * Similar to types.js but in TypeScript syntax because some types here need it. + * Downside is it can't reference any ambient types, which most of agoric-sdk type are presently. + */ + +import { ERef, FarRef } from '@endo/far'; +import type { CapData } from '@endo/marshal'; +import type { MsgWalletSpendAction } from '@agoric/cosmic-proto/swingset/msgs'; + +declare const CapDataShape: unique symbol; + +export type WalletCapData = CapData & { [CapDataShape]: T }; + +/** + * A petname can either be a plain string or a path for which the first element + * is a petname for the origin, and the rest of the elements are a snapshot of + * the names that were first given by that origin. We are migrating away from + * using plain strings, for consistency. + */ +export type Petname = string | string[]; + +export type RemotePurse = FarRef>; + +export type RemoteInvitationMakers = FarRef< + Record Promise> +>; + +export type Cell = { + get: () => T; + set(val: T): void; +}; + +export type BridgeActionCapData = WalletCapData< + import('./smartWallet.js').BridgeAction +>; + +/** + * Defined by walletAction struct in msg_server.go + * + * @see {MsgWalletSpendAction} and walletSpendAction in msg_server.go + */ +export type WalletActionMsg = { + type: 'WALLET_ACTION'; + /** base64 of Uint8Array of bech32 data */ + owner: string; + /** JSON of BridgeActionCapData */ + action: string; + blockHeight: unknown; // int64 + blockTime: unknown; // int64 +}; + +/** + * Defined by walletSpendAction struct in msg_server.go + * + * @see {MsgWalletSpendAction} and walletSpendAction in msg_server.go + */ +export type WalletSpendActionMsg = { + type: 'WALLET_SPEND_ACTION'; + /** base64 of Uint8Array of bech32 data */ + owner: string; + /** JSON of BridgeActionCapData */ + spendAction: string; + blockHeight: unknown; // int64 + blockTime: unknown; // int64 +}; + +/** + * Messages transmitted over Cosmos chain, cryptographically verifying that the + * message came from the 'owner'. + * + * The two wallet actions are distinguished by whether the user had to confirm + * the sending of the message (as is the case for WALLET_SPEND_ACTION). + */ +export type WalletBridgeMsg = + | ({ owner: string } & WalletActionMsg) + | WalletSpendActionMsg; diff --git a/packages/smart-wallet/src/utils.js b/packages/smart-wallet/src/utils.js new file mode 100644 index 00000000000..a2d2314bb3c --- /dev/null +++ b/packages/smart-wallet/src/utils.js @@ -0,0 +1,46 @@ +// @ts-check + +import { observeIteration, subscribeEach } from '@agoric/notifier'; + +/** + * Coalesce updates from a wallet UpdateRecord publication feed. Note that local + * state may not reflect the wallet's state if the initial updates are missed. + * + * If this proves to be a problem we can add an option to this or a related + * utility to reset state from RPC. + * + * @param {ERef>} updates + */ +export const coalesceUpdates = updates => { + /** @type {Map} */ + const brands = new Map(); + /** @type {{ [id: number]: import('./offers').OfferStatus}} */ + const offerStatuses = {}; + /** @type {Map} */ + const balances = new Map(); + observeIteration(subscribeEach(updates), { + updateState: updateRecord => { + const { updated } = updateRecord; + switch (updateRecord.updated) { + case 'balance': { + const { currentAmount } = updateRecord; + balances.set(currentAmount.brand, currentAmount); + break; + } + case 'offerStatus': { + const { status } = updateRecord; + offerStatuses[status.id] = status; + break; + } + case 'brand': { + const { descriptor } = updateRecord; + brands.set(descriptor.brand, descriptor); + break; + } + default: + throw new Error(`unknown record updated ${updated}`); + } + }, + }); + return { brands, offerStatuses, balances }; +}; diff --git a/packages/smart-wallet/src/walletFactory.js b/packages/smart-wallet/src/walletFactory.js new file mode 100644 index 00000000000..abd59374fa9 --- /dev/null +++ b/packages/smart-wallet/src/walletFactory.js @@ -0,0 +1,119 @@ +// @ts-check +/** + * @file Wallet Factory + * + * Contract to make smart wallets. + */ + +import { BridgeId } from '@agoric/internal'; +import { makeAtomicProvider } from '@agoric/store/src/stores/store-utils.js'; +import { makeScalarBigMapStore } from '@agoric/vat-data'; +import { E, Far } from '@endo/far'; +import { makeSmartWallet } from './smartWallet.js'; + +/** + * @typedef {{ + * agoricNames: ERef, + * board: ERef, + * }} SmartWalletContractTerms + */ + +// NB: even though all the wallets share this contract, they +// 1. they should rely on that; they may be partitioned later +// 2. they should never be able to detect behaviors from another wallet +/** + * + * @param {ZCF} zcf + * @param {{ + * storageNode: ERef, + * bridgeManager?: ERef, + * }} privateArgs + */ +export const start = async (zcf, privateArgs) => { + const { agoricNames, board } = zcf.getTerms(); + assert(board, 'missing board'); + assert(agoricNames, 'missing agoricNames'); + const zoe = zcf.getZoeService(); + const { storageNode, bridgeManager } = privateArgs; + assert(storageNode, 'missing storageNode'); + + /** @type {MapStore} */ + const walletsByAddress = makeScalarBigMapStore('walletsByAddress'); + const provider = makeAtomicProvider(walletsByAddress); + + // TODO(6062) refactor to a Far Class with type guards + const handleWalletAction = Far('walletActionHandler', { + /** + * + * @param {string} srcID + * @param {import('./types.js').WalletBridgeMsg} obj + */ + fromBridge: async (srcID, obj) => { + console.log('walletFactory.fromBridge:', srcID, obj); + assert(obj, 'missing wallet action'); + assert.typeof(obj, 'object'); + assert.typeof(obj.owner, 'string'); + const canSpend = 'spendAction' in obj; + assert( + canSpend || 'action' in obj, + 'missing action/spendAction property', + ); + const actionCapDataStr = canSpend ? obj.spendAction : obj.action; + // xxx capData body is also a JSON string so this is double-encoded + // revisit after https://github.com/Agoric/agoric-sdk/issues/2589 + const actionCapData = JSON.parse(actionCapDataStr); + // TODO(6062) validate shape before sending to wallet + + const wallet = walletsByAddress.get(obj.owner); // or throw + + console.log('walletFactory:', { wallet, actionCapData }); + return E(wallet).handleBridgeAction(actionCapData, canSpend); + }, + }); + + // NOTE: both `MsgWalletAction` and `MsgWalletSpendAction` arrive as BRIDGE_ID.WALLET + // by way of makeBlockManager() in cosmic-swingset/src/block-manager.js + await (bridgeManager && + E(bridgeManager).register(BridgeId.WALLET, handleWalletAction)); + + // Each wallet has `zoe` it can use to look them up, but pass these in to save that work. + const invitationIssuer = await E(zoe).getInvitationIssuer(); + const invitationBrand = await invitationIssuer.getBrand(); + + const shared = { + agoricNames, + board, + invitationBrand, + invitationIssuer, + storageNode, + zoe, + }; + + /** + * + * @param {string} address + * @param {ERef} bank + * @param {ERef} myAddressNameAdmin + * @returns {Promise} + */ + const provideSmartWallet = async (address, bank, myAddressNameAdmin) => { + assert.typeof(address, 'string', 'invalid address'); + assert(bank, 'missing bank'); + assert(myAddressNameAdmin, 'missing myAddressNameAdmin'); + + /** @type {() => Promise} */ + const maker = () => + makeSmartWallet({ address, bank }, shared).then(wallet => { + E(myAddressNameAdmin).update('depositeFacet', wallet.getDepositFacet()); + return wallet; + }); + + return provider.provideAsync(address, maker); + }; + + return { + creatorFacet: Far('walletFactoryCreator', { + provideSmartWallet, + }), + }; +}; diff --git a/packages/smart-wallet/test/contexts.js b/packages/smart-wallet/test/contexts.js new file mode 100644 index 00000000000..8bf1c483d04 --- /dev/null +++ b/packages/smart-wallet/test/contexts.js @@ -0,0 +1,96 @@ +import { deeplyFulfilledObject } from '@agoric/internal'; +import { unsafeMakeBundleCache } from '@agoric/swingset-vat/tools/bundleTool.js'; +import { makeStorageNodeChild } from '@agoric/vats/src/lib-chainStorage.js'; +import { makeNameHubKit } from '@agoric/vats/src/nameHub.js'; +import { E, Far } from '@endo/far'; +import path from 'path'; +import { withAmountUtils } from './supports.js'; + +/** + * @param {import('ava').ExecutionContext} t + * @param {(logger) => Promise} makeSpace + */ +export const makeDefaultTestContext = async (t, makeSpace) => { + // To debug, pass t.log instead of null logger + const log = () => null; + const { consume } = await makeSpace(log); + const { agoricNames, zoe } = consume; + + // Adapted from perAddress in makeAddressNameHubs() + const reserveAddress = address => { + // Create a name hub for this address. + const { nameHub: myAddressNameHub, nameAdmin: rawMyAddressNameAdmin } = + makeNameHubKit(); + + /** @type {MyAddressNameAdmin} */ + const myAddressNameAdmin = Far('myAddressNameAdmin', { + ...rawMyAddressNameAdmin, + getMyAddress: () => address, + }); + // reserve space for deposit facet + myAddressNameAdmin.reserve('depositFacet'); + // Register it with the namesByAddress hub. + return E(consume.namesByAddressAdmin).update( + address, + myAddressNameHub, + myAddressNameAdmin, + ); + }; + + // #region Installs + const pathname = new URL(import.meta.url).pathname; + const dirname = path.dirname(pathname); + + const bundleCache = await unsafeMakeBundleCache('bundles/'); + const bundle = await bundleCache.load( + `${dirname}/../src/walletFactory.js`, + 'walletFactory', + ); + /** @type {Promise>} */ + const installation = E(zoe).install(bundle); + // #endregion + + // copied from makeClientBanks() + const storageNode = await makeStorageNodeChild( + consume.chainStorage, + 'wallet', + ); + + const bridgeManager = await consume.bridgeManager; + const walletFactory = await E(zoe).startInstance( + installation, + {}, + { + agoricNames, + board: consume.board, + }, + { storageNode, bridgeManager }, + ); + + const simpleProvideWallet = async address => { + await reserveAddress(address); + + // copied from makeClientBanks() + const bank = E(consume.bankManager).getBankForAddress(address); + const myAddressNameAdmin = E(consume.namesByAddressAdmin).lookupAdmin( + address, + ); + + return E(walletFactory.creatorFacet).provideSmartWallet( + address, + bank, + myAddressNameAdmin, + ); + }; + + const anchor = withAmountUtils( + await deeplyFulfilledObject(consume.testFirstAnchorKit), + ); + + return { + anchor, + sendToBridge: bridgeManager && bridgeManager.toBridge, + consume, + simpleProvideWallet, + }; +}; diff --git a/packages/smart-wallet/test/devices.js b/packages/smart-wallet/test/devices.js new file mode 100644 index 00000000000..909348f6e0d --- /dev/null +++ b/packages/smart-wallet/test/devices.js @@ -0,0 +1,23 @@ +import bundleCentralSupply from '@agoric/vats/bundles/bundle-centralSupply.js'; +import bundleMintHolder from '@agoric/vats/bundles/bundle-mintHolder.js'; +import bundleWalletFactory from '@agoric/vats/bundles/bundle-legacy-walletFactory.js'; + +export const devices = { + vatAdmin: { + getNamedBundleCap: name => ({ + getBundle: () => { + switch (name) { + case 'centralSupply': + return bundleCentralSupply; + case 'mintHolder': + return bundleMintHolder; + // TODO(PS0) replace this bundle with the non-legacy smart-wallet + case 'walletFactory': + return bundleWalletFactory; + default: + throw new Error(`unknown bundle ${name}`); + } + }, + }), + }, +}; diff --git a/packages/smart-wallet/test/supports.js b/packages/smart-wallet/test/supports.js new file mode 100644 index 00000000000..ae199516f7a --- /dev/null +++ b/packages/smart-wallet/test/supports.js @@ -0,0 +1,218 @@ +// @ts-check + +import * as ActionType from '@agoric/cosmic-swingset/src/action-types.js'; +import { AmountMath, makeIssuerKit } from '@agoric/ertp'; +import { connectFaucet } from '@agoric/inter-protocol/src/proposals/demoIssuers.js'; +import centralSupplyBundle from '@agoric/vats/bundles/bundle-centralSupply.js'; +import { + installBootContracts, + makeAddressNameHubs, + makeBoard, +} from '@agoric/vats/src/core/basic-behaviors.js'; +import { setupClientManager } from '@agoric/vats/src/core/chain-behaviors.js'; +import '@agoric/vats/src/core/types.js'; +import { + makeAgoricNamesAccess, + makePromiseSpace, +} from '@agoric/vats/src/core/utils.js'; +import { buildRootObject as boardRoot } from '@agoric/vats/src/vat-board.js'; +import { buildRootObject as mintsRoot } from '@agoric/vats/src/vat-mints.js'; +import { makeMockChainStorageRoot } from '@agoric/vats/tools/storage-test-utils.js'; +import { makeZoeKit } from '@agoric/zoe'; +import { makeRatio } from '@agoric/zoe/src/contractSupport/ratio.js'; +import { eventLoopIteration } from '@agoric/zoe/tools/eventLoopIteration.js'; +import { makeFakeVatAdmin } from '@agoric/zoe/tools/fakeVatAdmin.js'; +import { makeLoopback } from '@endo/captp'; +import { E, Far } from '@endo/far'; +import { devices } from './devices.js'; + +export { ActionType }; + +/** + * @param {object} kit + * @param {Brand<'nat'>} kit.brand + * @param {Issuer<'nat'>} kit.issuer + * @param {Mint<'nat'>} [kit.mint] + */ +export const withAmountUtils = kit => { + return { + ...kit, + /** + * @param {NatValue} v + */ + make: v => AmountMath.make(kit.brand, v), + makeEmpty: () => AmountMath.makeEmpty(kit.brand), + /** + * @param {NatValue} n + * @param {NatValue} [d] + */ + makeRatio: (n, d) => makeRatio(n, kit.brand, d), + }; +}; +/** @typedef {ReturnType} AmountUtils */ + +/** + * + * @param {Promise} subscription + */ +export const subscriptionKey = subscription => { + return E(subscription) + .getStoreKey() + .then(storeKey => { + const [prefix, unique] = storeKey.storeSubkey.split(':'); + assert( + prefix === 'fake', + 'subscriptionKey helper only supports fake storage', + ); + return unique; + }); +}; + +const setUpZoeForTest = async () => { + const { makeFar } = makeLoopback('zoeTest'); + const { zoeService, feeMintAccess: nonFarFeeMintAccess } = makeZoeKit( + makeFakeVatAdmin(() => {}).admin, + ); + /** @type {import('@endo/far').ERef} */ + const zoe = makeFar(zoeService); + const feeMintAccess = await makeFar(nonFarFeeMintAccess); + return { + zoe, + feeMintAccess, + }; +}; +harden(setUpZoeForTest); + +const makeFakeBridgeManager = () => { + /** @type {Record>} */ + const handlers = {}; + /** @type {import('@agoric/vats/src/bridge').BridgeManager} */ + const manager = { + register(srcID, handler) { + handlers[srcID] = handler; + }, + toBridge(dstID, obj) { + const handler = handlers[dstID]; + assert(handler, `No handler for ${dstID}`); + switch (obj.type) { + case ActionType.WALLET_ACTION: + case ActionType.WALLET_SPEND_ACTION: { + return E(handler).fromBridge(dstID, obj); + } + + default: { + assert.fail(`Unsupported bridge object type ${obj.type}`); + } + } + }, + unregister(_srcID) { + assert.fail('expected unregister'); + }, + }; + return Far('fakeBridgeManager', manager); +}; + +/** + * + * @param {*} log + * @returns {Promise}>} + */ +export const makeMockTestSpace = async log => { + const space = /** @type {any} */ (makePromiseSpace(log)); + const { consume, produce } = + /** @type { BootstrapPowers & { consume: { loadVat: (n: 'mints') => MintsVat }} } */ ( + space + ); + const { agoricNames, spaces } = makeAgoricNamesAccess(); + produce.agoricNames.resolve(agoricNames); + + const { zoe, feeMintAccess } = await setUpZoeForTest(); + produce.zoe.resolve(zoe); + produce.feeMintAccess.resolve(feeMintAccess); + + produce.loadVat.resolve(name => { + switch (name) { + case 'mints': + return mintsRoot(); + case 'board': + return boardRoot(); + default: + throw Error('unknown loadVat name'); + } + }); + + const bldKit = makeIssuerKit('BLD'); + produce.bldIssuerKit.resolve(bldKit); + produce.bridgeManager.resolve(makeFakeBridgeManager()); + + const storageRoot = makeMockChainStorageRoot(); + produce.chainStorage.resolve(storageRoot); + + produce.testFirstAnchorKit.resolve(makeIssuerKit('AUSD', 'nat')); + + produce.bankManager.resolve( + Promise.resolve( + Far( + 'mockBankManager', + /** @type {any} */ ({ + getBankForAddress: _a => + Far('mockBank', { + getPurse: () => ({ + deposit: async (_, _x) => { + assert.fail('not impl'); + }, + }), + getAssetSubscription: () => assert.fail('not impl'), + }), + }), + ), + ), + ); + + const vatPowers = { + D: x => x, + }; + + await Promise.all([ + // @ts-expect-error + makeBoard({ consume, produce, ...spaces }), + makeAddressNameHubs({ consume, produce, ...spaces }), + installBootContracts({ vatPowers, devices, consume, produce, ...spaces }), + setupClientManager({ consume, produce, ...spaces }), + connectFaucet({ consume, produce, ...spaces }), + ]); + + return space; +}; + +/** + * @param {bigint} value + * @param {{ + * feeMintAccess: ERef, + * zoe: ERef, + * }} powers + * @returns {Promise} + */ +export const mintCentralPayment = async ( + value, + { feeMintAccess: feeMintAccessP, zoe }, +) => { + const feeMintAccess = await feeMintAccessP; + + const centralSupply = await E(zoe).install(centralSupplyBundle); + + const { creatorFacet: supplier } = await E(zoe).startInstance( + centralSupply, + {}, + { bootstrapPaymentValue: value }, + { feeMintAccess }, + ); + return E(supplier).getBootstrapPayment(); +}; + +/** @type {(subscriber: ERef>) => Promise} */ +export const currentState = async subscriber => { + await eventLoopIteration(); + const record = await E(subscriber).subscribeAfter(); + return record.head.value; +}; diff --git a/packages/smart-wallet/test/test-amm-integration.js b/packages/smart-wallet/test/test-amm-integration.js new file mode 100644 index 00000000000..5bcc24cda6d --- /dev/null +++ b/packages/smart-wallet/test/test-amm-integration.js @@ -0,0 +1,8 @@ +// @ts-check + +import test from 'ava'; + +// defer to after ps0 +test.todo('trade amm'); +// make a smart wallet +// suggestIssuer diff --git a/packages/smart-wallet/test/test-psm-integration.js b/packages/smart-wallet/test/test-psm-integration.js new file mode 100644 index 00000000000..db87474d3de --- /dev/null +++ b/packages/smart-wallet/test/test-psm-integration.js @@ -0,0 +1,268 @@ +// @ts-check +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { AmountMath } from '@agoric/ertp'; +import { buildRootObject as buildPSMRootObject } from '@agoric/vats/src/core/boot-psm.js'; +import '@agoric/vats/src/core/types.js'; +import { Stable } from '@agoric/vats/src/tokens.js'; +import { + mockDProxy, + mockPsmBootstrapArgs, +} from '@agoric/vats/tools/boot-test-utils.js'; +import { eventLoopIteration } from '@agoric/zoe/tools/eventLoopIteration.js'; +import { E } from '@endo/far'; +import { NonNullish } from '@agoric/assert'; +import { coalesceUpdates } from '../src/utils.js'; +import { makeDefaultTestContext } from './contexts.js'; + +/** + * @type {import('ava').TestFn> + * & {consume: import('@agoric/inter-protocol/src/proposals/econ-behaviors.js').EconomyBootstrapPowers['consume']}> + * } + */ +const test = anyTest; + +const committeeAddress = 'psmTestAddress'; + +const makePsmTestSpace = async log => { + const psmParams = { + anchorAssets: [{ denom: 'ibc/usdc1234', keyword: 'AUSD' }], + economicCommitteeAddresses: [committeeAddress], + argv: { bootMsg: {} }, + }; + + const psmVatRoot = await buildPSMRootObject( + { + logger: log, + D: mockDProxy, + }, + psmParams, + ); + psmVatRoot.bootstrap(...mockPsmBootstrapArgs(log)); + + // @ts-expect-error cast + return /** @type {ChainBootstrapSpace} */ (psmVatRoot.getPromiseSpace()); +}; + +test.before(async t => { + // @ts-expect-error cast + t.context = await makeDefaultTestContext(t, makePsmTestSpace); +}); +/** + * @param {Awaited>} state + * @param {Brand<'nat'>} brand + */ +const purseBalance = (state, brand) => { + const balances = Array.from(state.balances.values()); + const match = balances.find(b => b.brand === brand); + if (!match) { + console.debug('balances', ...balances); + assert.fail(`${brand} not found in record`); + } + return match.value; +}; + +test('null swap', async t => { + const { anchor } = t.context; + const { agoricNames } = await E.get(t.context.consume); + const mintedBrand = await E(agoricNames).lookup('brand', 'IST'); + + const wallet = await t.context.simpleProvideWallet('agoric1nullswap'); + const computedState = coalesceUpdates(E(wallet).getUpdatesSubscriber()); + const offersFacet = wallet.getOffersFacet(); + + const psmInstance = await E(agoricNames).lookup('instance', 'psm.IST.AUSD'); + + /** @type {import('../src/invitations.js').ContractInvitationSpec} */ + const invitationSpec = { + source: 'contract', + instance: psmInstance, + publicInvitationMaker: 'makeGiveMintedInvitation', + }; + /** @type {import('../src/offers').OfferSpec} */ + const offerSpec = { + id: 1, + invitationSpec, + proposal: { + // empty amounts + give: { In: AmountMath.makeEmpty(mintedBrand) }, + want: { Out: anchor.makeEmpty() }, + }, + }; + /** @type {import('../src/types').WalletCapData} */ + + // let promises settle to notify brands and create purses + await eventLoopIteration(); + + await offersFacet.executeOffer(offerSpec); + await eventLoopIteration(); + + t.is(purseBalance(computedState, anchor.brand), 0n); + t.is(purseBalance(computedState, mintedBrand), 0n); + + // success if nothing threw + t.pass(); +}); + +// we test this direciton of swap because wanting anchor would require the PSM to have anchor in it first +test('want stable', async t => { + const { anchor } = t.context; + const { agoricNames } = await E.get(t.context.consume); + + const swapSize = 10_000n; + + t.log('Start the PSM to ensure brands are registered'); + const psmInstance = await E(agoricNames).lookup('instance', 'psm.IST.AUSD'); + const stableBrand = await E(agoricNames).lookup('brand', Stable.symbol); + + const wallet = await t.context.simpleProvideWallet('agoric1wantstable'); + const computedState = coalesceUpdates(E(wallet).getUpdatesSubscriber()); + + const offersFacet = wallet.getOffersFacet(); + // let promises settle to notify brands and create purses + await eventLoopIteration(); + + t.is(purseBalance(computedState, anchor.brand), 0n); + + t.log('Fund the wallet'); + assert(anchor.mint); + const payment = anchor.mint.mintPayment(anchor.make(swapSize)); + await wallet.getDepositFacet().receive(payment, anchor.brand); + + t.log('Prepare the swap'); + + /** @type {import('../src/invitations.js').ContractInvitationSpec} */ + const invitationSpec = { + source: 'contract', + instance: psmInstance, + publicInvitationMaker: 'makeWantMintedInvitation', + }; + /** @type {import('../src/offers').OfferSpec} */ + const offerSpec = { + id: 1, + invitationSpec, + proposal: { + give: { In: anchor.make(swapSize) }, + want: {}, + }, + }; + + t.log('Execute the swap'); + await offersFacet.executeOffer(offerSpec); + await eventLoopIteration(); + t.is(purseBalance(computedState, anchor.brand), 0n); + t.is(purseBalance(computedState, stableBrand), swapSize - 1n); +}); + +test('govern offerFilter', async t => { + const { anchor } = t.context; + const { agoricNames, economicCommitteeCreatorFacet, psmFacets, zoe } = + await E.get(t.context.consume); + + const psmGovernorCreatorFacet = E.get( + E(psmFacets).get(anchor.brand), + ).psmGovernorCreatorFacet; + + const wallet = await t.context.simpleProvideWallet(committeeAddress); + const computedState = coalesceUpdates(E(wallet).getUpdatesSubscriber()); + const offersFacet = wallet.getOffersFacet(); + + t.log('Deposit voter invitation into wallet'); + { + const invitations = await E( + economicCommitteeCreatorFacet, + ).getVoterInvitations(); + const voterInvitation = await invitations[0]; + t.assert( + await E(E(zoe).getInvitationIssuer()).isLive(voterInvitation), + 'invalid invitation', + ); + wallet + .getDepositFacet() + .receive(voterInvitation, voterInvitation.getAllegedBrand()); + } + + t.log('Set up question'); + const binaryVoteCounterInstallation = await E(agoricNames).lookup( + 'installation', + 'binaryVoteCounter', + ); + const { details } = await E(psmGovernorCreatorFacet).voteOnOfferFilter( + binaryVoteCounterInstallation, + 2n, + harden(['wantStable']), + ); + const { positions, questionHandle } = await details; + const yesFilterOffers = positions[0]; + + t.log('Prepare offer to voting invitation in purse'); + { + // get invitation details the way a user would + const invitationDetails = await E(E(zoe).getInvitationIssuer()) + .getBrand() + .then(brand => { + /** @type {Amount<'set'>} */ + const invitationsAmount = NonNullish(computedState.balances.get(brand)); + t.is(invitationsAmount?.value.length, 1); + return invitationsAmount.value[0]; + }); + + /** @type {import('../src/invitations.js').PurseInvitationSpec} */ + const invitationSpec = { + source: 'purse', + instance: await E(agoricNames).lookup('instance', 'economicCommittee'), + description: invitationDetails.description, + }; + /** @type {import('../src/offers').OfferSpec} */ + const offerSpec = { + id: 33, + invitationSpec, + proposal: {}, + }; + /** @type {import('../src/types').WalletCapData} */ + t.log('Execute offer for the invitation'); + await offersFacet.executeOffer(offerSpec); + } + await eventLoopIteration(); + t.like(computedState.offerStatuses[33], { + id: 33, + numWantsSatisfied: 1, + // result has invitationMakers, but as a far object it can't be tested with .like() + }); + + t.log('Prepare offer to continue invitation'); + { + /** @type {import('../src/invitations.js').ContinuingInvitationSpec} */ + const invitationSpec = { + source: 'continuing', + previousOffer: 33, + invitationMakerName: 'makeVoteInvitation', + invitationArgs: [questionHandle], + }; + /** @type {import('../src/offers').OfferSpec} */ + const offerSpec = { + id: 44, + invitationSpec, + offerArgs: { positions: [yesFilterOffers] }, + proposal: {}, + }; + /** @type {import('../src/types').WalletCapData} */ + + // wait for the previousOffer result to get into the purse + await eventLoopIteration(); + await offersFacet.executeOffer(offerSpec); + } + + t.log('Make sure vote happened'); + await eventLoopIteration(); + t.like(computedState.offerStatuses[44], { + id: 44, + result: { chosen: { strings: ['wantStable'] }, shares: 1n }, + }); +}); + +test.todo('bad offer schema'); +test.todo('not enough funds'); +test.todo( + 'a faulty issuer that never returns and additional offers can still flow', +); diff --git a/packages/smart-wallet/test/test-stub.js b/packages/smart-wallet/test/test-stub.js deleted file mode 100644 index c653368d276..00000000000 --- a/packages/smart-wallet/test/test-stub.js +++ /dev/null @@ -1,3 +0,0 @@ -import test from 'ava'; - -test.todo('new contract'); diff --git a/packages/smart-wallet/test/test-walletFactory.js b/packages/smart-wallet/test/test-walletFactory.js new file mode 100644 index 00000000000..3e0a3da3ab7 --- /dev/null +++ b/packages/smart-wallet/test/test-walletFactory.js @@ -0,0 +1,111 @@ +/* eslint-disable no-await-in-loop */ +// @ts-check + +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { BridgeId } from '@agoric/internal'; +import { makeImportContext } from '@agoric/wallet-backend/src/marshal-contexts.js'; +import { eventLoopIteration } from '@agoric/zoe/tools/eventLoopIteration.js'; +import { E } from '@endo/far'; +import { makeDefaultTestContext } from './contexts.js'; +import { + ActionType, + currentState, + makeMockTestSpace, + subscriptionKey, +} from './supports.js'; + +import '@agoric/vats/src/core/types.js'; + +/** @type {import('ava').TestFn>>} */ +const test = anyTest; + +const mockAddress1 = 'mockAddress1'; +const mockAddress2 = 'mockAddress2'; +const mockAddress3 = 'mockAddress3'; + +test.before(async t => { + t.context = await makeDefaultTestContext(t, makeMockTestSpace); +}); + +test('bridge handler', async t => { + const smartWallet = await t.context.simpleProvideWallet(mockAddress1); + const updates = await E(smartWallet).getUpdatesSubscriber(); + t.truthy(updates); + + const lastUpdate = () => currentState(updates); + + const ctx = makeImportContext(); + + // fund the wallet with anchor + + /** @type {import('../src/offers.js').OfferSpec} */ + const offerSpec = { + id: 1, + invitationSpec: { + source: 'purse', + description: 'bogus', + // @ts-expect-error invalid offer for error + instance: null, + }, + proposal: {}, + }; + + t.like(await lastUpdate(), { + updated: 'balance', + currentAmount: { + value: [], + }, + }); + + assert(t.context.sendToBridge); + const res = await t.context.sendToBridge(BridgeId.WALLET, { + type: ActionType.WALLET_ACTION, + owner: mockAddress1, + // consider a helper for each action type + action: JSON.stringify( + ctx.fromBoard.serialize( + harden({ method: 'executeOffer', offer: offerSpec }), + ), + ), + }); + t.is(res, undefined); + + await eventLoopIteration(); + + t.deepEqual(await lastUpdate(), { + updated: 'offerStatus', + status: { + ...offerSpec, + error: 'Error: A Zoe invitation is required, not "[Promise]"', + }, + }); +}); + +test.todo('spend action over bridge'); + +test('notifiers', async t => { + async function checkAddress(address) { + const smartWallet = await t.context.simpleProvideWallet(address); + + const updates = await E(smartWallet).getUpdatesSubscriber(); + + t.is( + // @ts-expect-error faulty typedef + await subscriptionKey(updates), + `mockChainStorageRoot.wallet.${address}`, + ); + } + + await Promise.all( + [mockAddress1, mockAddress2, mockAddress3].map(checkAddress), + ); +}); + +test.todo( + 'exit an active offer', + // scenario: committee decided the anchor is junk + // pause the PSM trading such that there is time to exit before offer resolves + // executeOffer to buy the junk (which can't resolve) + // exit the offer "oh I don't want to buy junk!" +); diff --git a/packages/vats/src/core/startWalletFactory.js b/packages/vats/src/core/startWalletFactory.js index f49d262e02d..2c4d3f548bf 100644 --- a/packages/vats/src/core/startWalletFactory.js +++ b/packages/vats/src/core/startWalletFactory.js @@ -55,6 +55,8 @@ export const startWalletFactory = async ({ /** @type {WalletFactoryStartResult} */ const x = await E(zoe).startInstance(walletFactory, {}, terms, { storageNode, + // POLA contract only needs to register for srcId='wallet' + // TODO consider a scoped attenuation of this bridge manager to just 'wallet' bridgeManager, }); walletFactoryStartResult.resolve(x);