Skip to content

Commit

Permalink
Merge pull request #11 from Agoric/dc-pay-contract
Browse files Browse the repository at this point in the history
feat: contract to pay someone
  • Loading branch information
dckc authored Mar 12, 2024
2 parents c49ac22 + 5e0fa9a commit 26412c4
Show file tree
Hide file tree
Showing 9 changed files with 648 additions and 7 deletions.
64 changes: 64 additions & 0 deletions contract/src/postal-service.contract.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// @ts-check
import { E, Far } from '@endo/far';
import { M, mustMatch } from '@endo/patterns';
import { withdrawFromSeat } from '@agoric/zoe/src/contractSupport/zoeHelpers.js';

const { keys, values } = Object;

/**
* @typedef {object} PostalSvcTerms
* @property {import('@agoric/vats').NameHub} namesByAddress
*/

/** @param {ZCF<PostalSvcTerms>} zcf */
export const start = zcf => {
const { namesByAddress, issuers } = zcf.getTerms();
mustMatch(namesByAddress, M.remotable('namesByAddress'));
console.log('postal-service issuers', Object.keys(issuers));

/**
* @param {string} addr
* @returns {ERef<DepositFacet>}
*/
const getDepositFacet = addr => {
assert.typeof(addr, 'string');
return E(namesByAddress).lookup(addr, 'depositFacet');
};

/**
* @param {string} addr
* @param {Payment} pmt
*/
const sendTo = (addr, pmt) => E(getDepositFacet(addr)).receive(pmt);

/** @param {string} recipient */
const makeSendInvitation = recipient => {
assert.typeof(recipient, 'string');

/** @type {OfferHandler} */
const handleSend = async seat => {
const { give } = seat.getProposal();
const depositFacet = await getDepositFacet(recipient);
const payouts = await withdrawFromSeat(zcf, seat, give);

// XXX partial failure? return payments?
await Promise.all(
values(payouts).map(pmtP =>
E.when(pmtP, pmt => E(depositFacet).receive(pmt)),
),
);
seat.exit();
return `sent ${keys(payouts).join(', ')}`;
};

return zcf.makeInvitation(handleSend, 'send');
};

const publicFacet = Far('postalSvc', {
lookup: (...path) => E(namesByAddress).lookup(...path),
getDepositFacet,
sendTo,
makeSendInvitation,
});
return { publicFacet };
};
91 changes: 91 additions & 0 deletions contract/src/postal-service.proposal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* @file core eval script* to start the postalService contract.
*
* The `permit` export specifies the corresponding permit.
*/
// @ts-check

import { E } from '@endo/far';
import { fixHub } from './fixHub.js';

const { Fail } = assert;

/**
* @typedef { typeof import('./postal-service.contract.js').start } PostalServiceFn
*
* @typedef {{
* produce: { postalServiceKit: Producer<unknown> },
* installation: {
* consume: { postalService: Promise<Installation<PostalServiceFn>> },
* produce: { postalService: Producer<Installation<PostalServiceFn>> },
* }
* instance: {
* consume: { postalService: Promise<StartedInstanceKit<PostalServiceFn>['instance']> },
* produce: { postalService: Producer<StartedInstanceKit<PostalServiceFn>['instance']> },
* }
* }} PostalServicePowers
*/

/**
* @param {BootstrapPowers} powers
* @param {{ options?: { postalService: {
* bundleID: string;
* issuerNames?: string[];
* }}}} [config]
*/
export const startPostalService = async (powers, config) => {
/** @type { BootstrapPowers & PostalServicePowers} */
// @ts-expect-error bootstrap powers evolve with BLD staker governance
const postalPowers = powers;
const {
consume: { zoe, namesByAddressAdmin, agoricNames },
installation: {
produce: { postalService: produceInstallation },
},
instance: {
produce: { postalService: produceInstance },
},
} = postalPowers;
const {
bundleID = Fail`no bundleID`,
issuerNames = ['IST', 'Invitation', 'BLD', 'ATOM'],
} = config?.options?.postalService ?? {};

/** @type {Installation<PostalServiceFn>} */
const installation = await E(zoe).installBundleID(bundleID);
produceInstallation.resolve(installation);

const namesByAddress = await fixHub(namesByAddressAdmin);

// XXX ATOM isn't available via consume.issuer.ATOM. Odd.
const issuers = Object.fromEntries(
issuerNames.map(n => [n, E(agoricNames).lookup('issuer', n)]),
);
const { instance } = await E(zoe).startInstance(installation, issuers, {
namesByAddress,
});
produceInstance.resolve(instance);

console.log('postalService started');
};

export const manifest = /** @type {const} */ ({
[startPostalService.name]: {
consume: {
agoricNames: true,
namesByAddress: true,
namesByAddressAdmin: true,
zoe: true,
},
installation: {
produce: { postalService: true },
},
instance: {
produce: { postalService: true },
},
},
});

export const permit = Object.values(manifest)[0];

export const main = startPostalService;
19 changes: 12 additions & 7 deletions contract/test/boot-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,28 +108,28 @@ export const mockBootstrapPowers = async (
};

/**
* @param {import('ava').ExecutionContext} t
* @param {BundleCache} bundleCache
* @param {Record<string, string>} bundleRoots
* @param {InstallBundle} installBundle
* @param {(...args: unknown[]) => void} log
*
* @typedef {(id: string, bundle: CachedBundle, name: string) => Promise<void>} InstallBundle
* @typedef {Awaited<ReturnType<import('@endo/bundle-source/cache.js').makeNodeBundleCache>>} BundleCache
* @typedef {{ moduleFormat: 'endoZipBase64', endoZipBase64Sha512: string }} CachedBundle
*/
export const installBundles = async (
t,
bundleCache,
bundleRoots,
installBundle,
log = console.log,
) => {
/** @type {Record<string, CachedBundle>} */
const bundles = {};
await null;
for (const [name, rootModulePath] of Object.entries(bundleRoots)) {
const bundle = await bundleCache.load(rootModulePath, name);
const bundleID = getBundleId(bundle);
t.log('publish bundle', name, bundleID.slice(0, 8));
log('publish bundle', name, bundleID.slice(0, 8));
await installBundle(bundleID, bundle, name);
bundles[name] = bundle;
}
Expand All @@ -143,10 +143,10 @@ export const bootAndInstallBundles = async (t, bundleRoots) => {
const { vatAdminState } = powersKit;

const bundles = await installBundles(
t,
t.context.bundleCache,
bundleRoots,
(bundleID, bundle, _name) => vatAdminState.installBundle(bundleID, bundle),
t.log,
);
return { ...powersKit, bundles };
};
Expand Down Expand Up @@ -199,7 +199,12 @@ export const makeMockTools = async (t, bundleCache) => {
);

let pid = 0;
const runCoreEval = async ({ behavior, config }) => {
const runCoreEval = async ({
behavior,
config,
entryFile: _e,
name: _todo,
}) => {
if (!behavior) throw Error('TODO: run core eval without live behavior');
await behavior(powers, config);
pid += 1;
Expand All @@ -226,8 +231,8 @@ export const makeMockTools = async (t, bundleCache) => {

return {
makeQueryTool,
installBundles: bundleRoots =>
installBundles(t, bundleCache, bundleRoots, installBundle),
installBundles: (bundleRoots, log) =>
installBundles(bundleCache, bundleRoots, installBundle, log),
runCoreEval,
provisionSmartWallet: async (addr, balances) => {
const it = await walletFactory.makeSmartWallet(addr);
Expand Down
169 changes: 169 additions & 0 deletions contract/test/market-actors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// @ts-check
import { E, getInterfaceOf } from '@endo/far';
import { AmountMath, AssetKind } from '@agoric/ertp/src/amountMath.js';
import { allValues, mapValues } from '../src/objectTools.js';
import { seatLike } from './wallet-tools.js';
import {
makeNameProxy,
makeAgoricNames,
} from './ui-kit-goals/name-service-client.js';

const { entries, fromEntries, keys } = Object;

/**
* @typedef {{
* brand: Record<string, Promise<Brand>> & { timer: unknown }
* issuer: Record<string, Promise<Issuer>>
* instance: Record<string, Promise<Instance>>
* installation: Record<string, Promise<Installation>>
* }} WellKnown
*/

/**
* @typedef {{
* assetKind: Map<Brand, AssetKind>
* }} WellKnownKinds
*/

/**
* @param {import('ava').ExecutionContext} t
* @param {{
* wallet: import('./wallet-tools.js').MockWallet;
* queryTool: Pick<import('./ui-kit-goals/queryKit.js').QueryTool, 'queryData'>;
* }} mine
* @param {{
* rxAddr: string,
* toSend: AmountKeywordRecord;
* }} shared
*/
export const payerPete = async (
t,
{ wallet, queryTool },
{ rxAddr, toSend },
) => {
const hub = await makeAgoricNames(queryTool);
/** @type {WellKnown} */
const agoricNames = makeNameProxy(hub);

const instance = await agoricNames.instance.postalService;

t.log('Pete offers to send to', rxAddr, 'via contract', instance);
/** @type {import('@agoric/smart-wallet/src/offers.js').OfferSpec} */
const sendOffer = {
id: 'peteSend1',
invitationSpec: {
source: 'contract',
instance,
publicInvitationMaker: 'makeSendInvitation',
invitationArgs: [rxAddr],
},
proposal: { give: toSend },
};
t.snapshot(sendOffer, 'client sends offer');
const updates = await E(wallet.offers).executeOffer(sendOffer);

const seat = seatLike(updates);
const payouts = await E(seat).getPayoutAmounts();
for (const [kwd, amt] of entries(payouts)) {
const { brand } = amt;
const kind = AssetKind.NAT; // TODO: handle non-fungible amounts
t.log('Pete payout should be empty', kwd, amt);
t.deepEqual(amt, AmountMath.makeEmpty(brand, kind));
}
};

const trackDeposits = async (t, initial, purseUpdates, toSend) =>
allValues(
fromEntries(
entries(initial).map(([name, _update]) => {
const amtP = purseUpdates[name].next().then(u => {
const expected = AmountMath.add(initial[name], toSend[name]);
t.log('updated balance', name, u.value);
t.deepEqual(u.value, expected);
return u.value;
});
return [name, amtP];
}),
),
);

/**
* Rose expects to receive `shared.toSend` amounts.
* She expects initial balances to be empty;
* and relies on `wellKnown.assetKind` to make an empty amount from a brand.
*
* @param {import('ava').ExecutionContext} t
* @param {{ wallet: import('./wallet-tools.js').MockWallet, }} mine
* @param {{ toSend: AmountKeywordRecord }} shared
*/
export const receiverRose = async (t, { wallet }, { toSend }) => {
console.time('rose');
console.timeLog('rose', 'before notifiers');
const purseNotifier = mapValues(toSend, amt =>
wallet.peek.purseUpdates(amt.brand),
);
console.timeLog('rose', 'after notifiers; before initial');

const initial = await allValues(
mapValues(purseNotifier, pn => pn.next().then(u => u.value)),
);
console.timeLog('rose', 'got initial', initial);
t.log('Rose initial', initial);
t.deepEqual(keys(initial), keys(toSend));

const done = await trackDeposits(t, initial, purseNotifier, toSend);
t.log('Rose got balance updates', keys(done));
t.deepEqual(keys(done), keys(toSend));
};

/**
* Rex expects to receive `shared.toSend` amounts.
* Rex doesn't check his initial balances
*
* @param {import('ava').ExecutionContext} t
* @param {{ wallet: import('./wallet-tools.js').MockWallet, }} mine
* @param {{ toSend: AmountKeywordRecord }} shared
*/
export const receiverRex = async (t, { wallet }, { toSend }) => {
const purseUpdates = await allValues(
mapValues(toSend, amt => E(wallet.peek).purseUpdates(amt.brand)),
);

const initial = await allValues(
mapValues(purseUpdates, up =>
E(up)
.next()
.then(u => u.value),
),
);

const done = await trackDeposits(t, initial, purseUpdates, toSend);

t.log('Rex got balance updates', keys(done));
t.deepEqual(keys(done), keys(toSend));
};

export const senderContract = async (
t,
{ zoe, terms: { postalService: instance, destAddr: addr1 } },
) => {
const iIssuer = await E(zoe).getInvitationIssuer();
const iBrand = await E(iIssuer).getBrand();
const postalService = E(zoe).getPublicFacet(instance);
const purse = await E(iIssuer).makeEmptyPurse();

const noInvitations = AmountMath.make(iBrand, harden([]));
const pmt1 = await E(purse).withdraw(noInvitations);

t.log(
'senderContract: E(',
getInterfaceOf(await postalService),
').sendTo(',
addr1,
',',
noInvitations,
')',
);
const sent = await E(postalService).sendTo(addr1, pmt1);
t.deepEqual(sent, noInvitations);
};
Loading

0 comments on commit 26412c4

Please sign in to comment.