Skip to content

Commit

Permalink
feat(smart-wallet): trading in non-vbank asset (WIP)
Browse files Browse the repository at this point in the history
WIP: test part 2: use a real contract

WIP: note limitation on depositFacet assetKind

chore(smart-wallet): never mind static check for remote presence

feat(smart-wallet): purses for well-known brands (WIP)

current challenge: "no ordinal" for Place brand

agoric-sdk/packages/smart-wallet$ yarn test test/test-addAsset.js -m '*non-vbank*'
...

wallet agoric1player1 OFFER ERROR: (Error#1)
Error#1: cannot encode Object [Alleged: Place brand] {} as key: no ordinal
  at encodeRemotable (packages/swingset-liveslots/src/collectionManager.js:284:13)
  at keyToDBKey (packages/swingset-liveslots/src/collectionManager.js:329:26)
  at Alleged: mapStore.set (packages/swingset-liveslots/src/collectionManager.js:431:21)
  at
  Object.providePurseForWellKnownBrand (.../smart-wallet/src/smartWallet.js:421:15)

fixup? MockChainStorage spelling

chore: test misc

 - clarify rpc
 - clean up makeHandle lint
 - never mind ignored fromEntries
 - another MockStorageRoot

chore: use side table for new purse storage

test: clean up game contract

chore: rename to gameAssetContract.js

test: fixup test-addAsset (SQUASHME)

chore: revert to queue payments for unknown brand (NEEDSTEST)

WIP: refine non-vbank asset to tell story in t.log

chore: make adding an issuer less specific to agoricNames

WIP: toward watching purses; types for brandToPurses
  • Loading branch information
dckc committed Jul 28, 2023
1 parent ba921b5 commit 98aebc5
Show file tree
Hide file tree
Showing 5 changed files with 524 additions and 17 deletions.
173 changes: 163 additions & 10 deletions packages/smart-wallet/src/smartWallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@ import {
PaymentShape,
PurseShape,
} from '@agoric/ertp';
import { StorageNodeShape } from '@agoric/internal';
import { StorageNodeShape, makeTracer } from '@agoric/internal';
import { observeNotifier } from '@agoric/notifier';
import { M, mustMatch } from '@agoric/store';
import { appendToStoredArray } from '@agoric/store/src/stores/store-utils.js';
import { makeScalarBigMapStore, prepareExoClassKit } from '@agoric/vat-data';
import {
appendToStoredArray,
provideLazy,
} from '@agoric/store/src/stores/store-utils.js';
import {
makeScalarBigMapStore,
makeScalarBigWeakMapStore,
prepareExoClassKit,
provide,
} from '@agoric/vat-data';
import {
prepareRecorderKit,
SubscriberShape,
Expand All @@ -25,6 +33,8 @@ import { objectMapStoragePath } from './utils.js';

const { Fail, quote: q } = assert;

const trace = makeTracer('SmrtWlt');

/**
* @file Smart wallet module
*
Expand Down Expand Up @@ -132,7 +142,7 @@ const { Fail, quote: q } = assert;
* - `purseBalances` is a cache of what we've received from purses. Held so we can publish all balances on change.
*
* @typedef {Readonly<UniqueParams & {
* paymentQueues: MapStore<Brand, Array<import('@endo/far').FarRef<Payment>>>,
* paymentQueues: MapStore<Brand, Array<Payment>>,
* offerToInvitationMakers: MapStore<string, import('./types').RemoteInvitationMakers>,
* offerToPublicSubscriberPaths: MapStore<string, Record<string, string>>,
* offerToUsedInvitation: MapStore<string, Amount>,
Expand All @@ -143,10 +153,47 @@ const { Fail, quote: q } = assert;
* liveOfferSeats: WeakMapStore<import('./offers.js').OfferId, UserSeat<unknown>>,
* }>} ImmutableState
*
* @typedef {BrandDescriptor & { purse: RemotePurse }} PurseRecord
* @typedef {{
* }} MutableState
*/

/**
* NameHub reverse-lookup, finding 0 or more names for a target value
*
* XXX move to nameHub.js?
*
* @param {unknown} target - passable Key
* @param {ERef<NameHub>} nameHub
*/
const namesOf = async (target, nameHub) => {
const entries = await E(nameHub).entries();
const matches = [];
for (const [name, candidate] of entries) {
if (candidate === target) {
matches.push(name);
}
}
return harden(matches);
};

/**
* Check that an issuer and its brand belong to each other.
*
* XXX move to ERTP?
*
* @param {Issuer} issuer
* @param {Brand} brand
*/
const mutualCheck = async (issuer, brand) => {
const [iBrand, mine] = await Promise.all([
E(issuer).getBrand(),
E(brand).isMyIssuer(issuer),
]);
brand === iBrand || Fail`brand of ${issuer} is ${iBrand}; expected ${brand}`;
mine || Fail`${brand} does not recognize ${issuer} as its own`;
};

/**
* @param {import('@agoric/vat-data').Baggage} baggage
* @param {SharedParams} shared
Expand All @@ -167,6 +214,16 @@ export const prepareSmartWallet = (baggage, shared) => {

const makeRecorderKit = prepareRecorderKit(baggage, shared.publicMarshaller);

const BRAND_TO_PURSES_KEY = 'brandToPurses';
const walletPurses = provide(baggage, BRAND_TO_PURSES_KEY, _k => {
trace('make purses by wallet and save in baggage at', BRAND_TO_PURSES_KEY);
/** @type {WeakMapStore<unknown, MapStore<Brand, PurseRecord[]>>} */
const store = makeScalarBigWeakMapStore('purses by wallet', {
durable: true,
});
return store;
});

/**
*
* @param {UniqueParams} unique
Expand Down Expand Up @@ -241,10 +298,15 @@ export const prepareSmartWallet = (baggage, shared) => {
};
};

const StoreShape = M.remotable();
const behaviorGuards = {
helper: M.interface('helperFacetI', {
assertUniqueOfferId: M.call(M.string()).returns(),
updateBalance: M.call(PurseShape, AmountShape).optional('init').returns(),
provideBrandToPurses: M.call().returns(StoreShape),
providePurseForKnownBrand: M.call(BrandShape)
.optional(M.eref(M.remotable()))
.returns(M.promise()),
publishCurrentState: M.call().returns(),
watchPurse: M.call(M.eref(PurseShape)).returns(M.promise()),
}),
Expand Down Expand Up @@ -369,6 +431,85 @@ export const prepareSmartWallet = (baggage, shared) => {
},
});
},

provideBrandToPurses() {
const brandToPurses = provideLazy(
walletPurses,
this.facets.helper,
_k => {
/** @type {MapStore<Brand, PurseRecord[]>} */
const store = makeScalarBigMapStore('purses by brand', {
durable: true,
});
return store;
},
);
return brandToPurses;
},

/**
* Provide a purse given a NameHub of issuers and their
* brands that we consider reliable.
*
* @param {Brand} brand
* @param {ERef<NameHub>} known - namehub with brand, issuer branches
* @returns {Promise<Purse | undefined>} undefined if brand is not well known
*/
async providePurseForKnownBrand(brand, known = shared.agoricNames) {
const brandToPurses = this.facets.helper.provideBrandToPurses();

if (brandToPurses.has(brand)) {
const purses = brandToPurses.get(brand);
if (purses.length > 0) {
assert(purses.length === 1);
// For now, we have just 1, so use it.
return purses[0].purse;
}
}

/**
* Introduce an issuer to this wallet.
*
* @param {Issuer} issuer
* @param {string} edgeName
*/
const addIssuer = async (issuer, edgeName) => {
// XXX consider checking i.getBrand() = b and brand.isMyIssuer(i)
const [displayInfo, purse] = await Promise.all([
E(issuer).getDisplayInfo(),
E(issuer).makeEmptyPurse(),
]);

// adopt edgeName as petname
// TODO: qualify edgename by nameHub name?
const petname = edgeName;
const assetInfo = { petname, brand, issuer, purse, displayInfo };
// TODO: handle >1 per brand
brandToPurses.init(brand, harden([assetInfo]));
return assetInfo;
};

const found = await namesOf(brand, E(known).lookup('brand'));
if (found.length === 0) {
return undefined;
}
const [edgeName] = found;
const issuer = await E(known).lookup('issuer', edgeName);

// Even though we rely on this nameHub, double-check
// that the issuer and the brand belong to each other.
try {
await mutualCheck(issuer, brand);
} catch (err) {
console.warn('issuer/brand mismatch', err);
return undefined;
}

const { purse } = await addIssuer(issuer, edgeName);
void this.facets.helper.watchPurse(purse);
trace(`@@@TODO: process queued payments with brand ${brand}`);
return purse;
},
},
/**
* Similar to {DepositFacet} but async because it has to look up the purse.
Expand All @@ -379,27 +520,34 @@ export const prepareSmartWallet = (baggage, shared) => {
*
* If the purse doesn't exist, we hold the payment in durable storage.
*
* @param {import('@endo/far').FarRef<Payment>} payment
* @param {Payment} payment
* @returns {Promise<Amount>} amounts for deferred deposits will be empty
*/
async receive(payment) {
const { helper } = this.facets;
const { paymentQueues: queues, bank, invitationPurse } = this.state;
const { registry, invitationBrand } = shared;
const brand = await E(payment).getAllegedBrand();

// When there is a purse deposit into it
if (registry.has(brand)) {
const purse = E(bank).getPurse(brand);
// @ts-expect-error deposit does take a FarRef<Payment>
return E(purse).deposit(payment);
} else if (invitationBrand === brand) {
// @ts-expect-error deposit does take a FarRef<Payment>
// @ts-expect-error narrow assetKind to 'set'
return E(invitationPurse).deposit(payment);
}

const purse = await helper.providePurseForKnownBrand(brand);
if (purse) {
return E(purse).deposit(payment);
}

// When there is no purse, save the payment into a queue.
// It's not yet ever read but a future version of the contract can
console.warn(`cannot deposit payment with brand ${brand}: no purse`);
appendToStoredArray(queues, brand, payment);
// @@NOTE: default 'nat' assetKind is not always right here
return AmountMath.makeEmpty(brand);
},
},
Expand Down Expand Up @@ -448,13 +596,18 @@ export const prepareSmartWallet = (baggage, shared) => {
* @returns {Promise<RemotePurse>}
*/
purseForBrand: async brand => {
const { helper } = facets;
if (registry.has(brand)) {
// @ts-expect-error RemotePurse cast
// @ts-expect-error Promise<T> vs. ERef<T>
return E(bank).getPurse(brand);
} else if (invitationBrand === brand) {
// @ts-expect-error RemotePurse cast
return invitationPurse;
}

const purse = await helper.providePurseForKnownBrand(brand);
if (purse) {
return purse;
}
throw Fail`cannot find/make purse for ${brand}`;
},
logger,
Expand Down Expand Up @@ -604,8 +757,8 @@ export const prepareSmartWallet = (baggage, shared) => {
const { invitationPurse } = state;
const { helper } = facets;

// @ts-expect-error RemotePurse cast
void helper.watchPurse(invitationPurse);
console.log('@@watch stored purses');
},
},
);
Expand Down
4 changes: 3 additions & 1 deletion packages/smart-wallet/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ declare const CapDataShape: unique symbol;
*/
export type Petname = string | string[];

export type RemotePurse<T = unknown> = FarRef<Purse<T>>;
// RemotePurse was an attempt to statically detect sync calls,
// but it doesn't fit well with our API.
export type RemotePurse<T = any> = Purse<T>;

export type RemoteInvitationMakers = FarRef<
Record<string, (...args: any[]) => Promise<Invitation>>
Expand Down
60 changes: 60 additions & 0 deletions packages/smart-wallet/test/gameAssetContract.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/** @file illustrates using non-vbank assets */

// deep import to avoid dependency on all of ERTP, vat-data
import { AmountMath, AssetKind } from '@agoric/ertp/src/amountMath.js';
import { makeTracer } from '@agoric/internal';
import { getCopyBagEntries } from '@agoric/store';
import { atomicRearrange } from '@agoric/zoe/src/contractSupport/index.js';
import { E, Far } from '@endo/far';

const { Fail, quote: q } = assert;

const trace = makeTracer('Game', true);

/** @param {Amount<'copyBag'>} amt */
const totalPlaces = amt => {
/** @type {[unknown, bigint][]} */
const entries = getCopyBagEntries(amt.value); // XXX getCopyBagEntries returns any???
const total = entries.reduce((acc, [_place, qty]) => acc + qty, 0n);
return total;
};

/**
* @param {ZCF<{joinPrice: Amount}>} zcf
*/
export const start = async zcf => {
const { joinPrice } = zcf.getTerms();
const stableIssuer = await E(zcf.getZoeService()).getFeeIssuer();
zcf.saveIssuer(stableIssuer, 'Price');

const { zcfSeat: gameSeat } = zcf.makeEmptySeatKit();
const mint = await zcf.makeZCFMint('Place', AssetKind.COPY_BAG);

/** @param {ZCFSeat} playerSeat */
const joinHook = playerSeat => {
const { give, want } = playerSeat.getProposal();
trace('join', 'give', give, 'want', want.Places.value);

AmountMath.isGTE(give.Price, joinPrice) ||
Fail`${q(give.Price)} below joinPrice of ${q(joinPrice)}}`;

totalPlaces(want.Places) <= 3n || Fail`only 3 places allowed when joining`;

atomicRearrange(
zcf,
harden([
[playerSeat, gameSeat, give],
[mint.mintGains(want), playerSeat, want],
]),
);
playerSeat.exit(true);
return 'welcome to the game';
};

const publicFacet = Far('API', {
makeJoinInvitation: () => zcf.makeInvitation(joinHook, 'join'),
});

return harden({ publicFacet });
};
harden(start);
4 changes: 3 additions & 1 deletion packages/smart-wallet/test/supports.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,10 @@ export const makeMockTestSpace = async log => {
/** @type { BootstrapPowers & { consume: { loadVat: (n: 'mints') => MintsVat, loadCriticalVat: (n: 'mints') => MintsVat }} } */ (
space
);
const { agoricNames, spaces } = await makeAgoricNamesAccess();
const { agoricNames, agoricNamesAdmin, spaces } =
await makeAgoricNamesAccess();
produce.agoricNames.resolve(agoricNames);
produce.agoricNamesAdmin.resolve(agoricNamesAdmin);

const { zoe, feeMintAccessP } = await setUpZoeForTest();
produce.zoe.resolve(zoe);
Expand Down
Loading

0 comments on commit 98aebc5

Please sign in to comment.