Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Hackathon] Subscription service contract #98

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contract/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
},
"dependencies": {
"@agoric/ertp": "^0.16.3-u12.0",
"@agoric/time": "^0.3.3-u16.0",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dapp dependencies are, unfortunately, tremendously fragile. Mixing u12 and u16 is asking for trouble, IME.

We should probably have a better scoped issue, but the tale of woe is in...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried rolling back to the u12 version of @agoric/time (60e2308) but the symptoms remain.

"@agoric/zoe": "^0.26.3-u12.0",
"@endo/far": "^0.2.22",
"@endo/marshal": "^0.8.9",
Expand Down
18 changes: 13 additions & 5 deletions contract/src/offer-up-proposal.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ const BOARD_AUX = 'boardAux';

const marshalData = makeMarshal(_val => Fail`data only`);

const IST_UNIT = 1_000_000n;
const CENT = IST_UNIT / 100n;

/**
* Make a storage node for auxilliary data for a value on the board.
*
Expand Down Expand Up @@ -44,7 +41,13 @@ const publishBrandInfo = async (chainStorage, board, brand) => {
export const startOfferUpContract = async permittedPowers => {
console.error('startOfferUpContract()...');
const {
consume: { board, chainStorage, startUpgradable, zoe },
consume: {
board,
chainStorage,
startUpgradable,
zoe,
chainTimerService: chainTimerServiceP,
},
brand: {
consume: { IST: istBrandP },
// @ts-expect-error dynamic extension to promise space
Expand All @@ -66,8 +69,12 @@ export const startOfferUpContract = async permittedPowers => {

const istIssuer = await istIssuerP;
const istBrand = await istBrandP;
const timerService = await await chainTimerServiceP;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know whether await await breaks anything, but it's at best unnecessary.


const terms = { tradePrice: AmountMath.make(istBrand, 25n * CENT) };
const terms = {
subscriptionPrice: AmountMath.make(istBrand, 10000000n),
timerService,
};

// agoricNames gets updated each time; the promise space only once XXXXXXX
const installation = await offerUpInstallationP;
Expand Down Expand Up @@ -107,6 +114,7 @@ const offerUpManifest = {
chainStorage: true, // to publish boardAux info for NFT brand
startUpgradable: true, // to start contract and save adminFacet
zoe: true, // to get contract terms, including issuer/brand
chainTimerService: true,
},
installation: { consume: { offerUp: true } },
issuer: { consume: { IST: true }, produce: { Item: true } },
Expand Down
129 changes: 81 additions & 48 deletions contract/src/offer-up.contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,58 +19,48 @@
*/
// @ts-check

import { Far } from '@endo/far';
import { M, getCopyBagEntries } from '@endo/patterns';
import { AssetKind } from '@agoric/ertp/src/amountMath.js';
import { AmountShape } from '@agoric/ertp/src/typeGuards.js';
import { Far, E } from '@endo/far';
import { AmountMath, AssetKind } from '@agoric/ertp/src/amountMath.js';
import { makeCopyBag, M } from '@endo/patterns';
import { atomicRearrange } from '@agoric/zoe/src/contractSupport/atomicTransfer.js';
import '@agoric/zoe/exported.js';

const { Fail, quote: q } = assert;

// #region bag utilities
/** @type { (xs: bigint[]) => bigint } */
const sum = xs => xs.reduce((acc, x) => acc + x, 0n);

/**
* @param {import('@endo/patterns').CopyBag} bag
* @returns {bigint[]}
*/
const bagCounts = bag => {
const entries = getCopyBagEntries(bag);
return entries.map(([_k, ct]) => ct);
};
// #endregion

/**
* In addition to the standard `issuers` and `brands` terms,
* this contract is parameterized by terms for price and,
* optionally, a maximum number of items sold for that price (default: 3).
*
* @typedef {{
* tradePrice: Amount;
* maxItems?: bigint;
* }} OfferUpTerms
* timerService: any;
* subscriptionPrice: Amount;
* subscriptionPeriod?: string;
* servicesToAvail?: Array<string>;
* }} SubscriptionServiceTerms
*/

export const meta = {
customTermsShape: M.splitRecord(
{ tradePrice: AmountShape },
{ maxItems: M.bigint() },
),
};
// compatibility with an earlier contract metadata API
export const customTermsShape = meta.customTermsShape;

/**
* Start a contract that
* - creates a new non-fungible asset type for Items, and
* - handles offers to buy up to `maxItems` items at a time.
*
* @param {ZCF<OfferUpTerms>} zcf
* @param {ZCF<SubscriptionServiceTerms>} zcf
*/
export const start = async zcf => {
const { tradePrice, maxItems = 3n } = zcf.getTerms();
const {
timerService,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timer service is sufficiently powerful that it should be closely held; i.e. passed in privateArgs rather than terms.
The main power that's risky is the power to schedule wake-ups. You don't seem to be using that here. Perhaps use getClock before starting the contract and pass in the resulting Clock in terms.

See also:

subscriptionPrice,
subscriptionPeriod = 'MONTHLY',

Check failure on line 52 in contract/src/offer-up.contract.js

View workflow job for this annotation

GitHub Actions / unit (18)

'subscriptionPeriod' is assigned a value but never used. Allowed unused vars must match /^_/u
servicesToAvail = ['Netflix', 'Amazon', 'HboMax', 'Disney'],
} = zcf.getTerms();

const subscriptionResources = {};

servicesToAvail.forEach(element => {

Check warning on line 58 in contract/src/offer-up.contract.js

View workflow job for this annotation

GitHub Actions / unit (18)

Prefer for...of instead of Array.forEach
subscriptionResources[element] = [
`${element}_Movie_1`,
`${element}_Movie_2`,
];
});

/**
* a new ERTP mint for items, accessed thru the Zoe Contract Facet.
Expand All @@ -81,7 +71,8 @@
* amounts such as: 3 potions and 1 map.
*/
const itemMint = await zcf.makeZCFMint('Item', AssetKind.COPY_BAG);
const { brand: itemBrand } = itemMint.getIssuerRecord();

const { brand } = itemMint.getIssuerRecord();

/**
* a pattern to constrain proposals given to {@link tradeHandler}
Expand All @@ -90,36 +81,47 @@
* The `Items` amount must use the `Item` brand and a bag value.
*/
const proposalShape = harden({
give: { Price: M.gte(tradePrice) },
want: { Items: { brand: itemBrand, value: M.bag() } },
give: { Price: M.eq(subscriptionPrice) },
want: { Items: { brand: M.any(), value: M.bag() } },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not itemBrand?

exit: M.any(),
});

/** a seat for allocating proceeds of sales */
const proceeds = zcf.makeEmptySeatKit().zcfSeat;

const subscriptions = new Map();

/** @type {OfferHandler} */
const tradeHandler = buyerSeat => {
// give and want are guaranteed by Zoe to match proposalShape
const { want } = buyerSeat.getProposal();
const tradeHandler = async (buyerSeat, offerArgs) => {
// @ts-ignore
const userAddress = offerArgs.userAddress;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the goal here?
This address is forgeable.
Can you key the map on seats instead of addresses?

Copy link
Member

@dckc dckc Sep 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// @ts-ignore
const serviceType = offerArgs.serviceType;
const currentTimeRecord = await E(timerService).getCurrentTimestamp();

const amountObject = AmountMath.make(
brand,
makeCopyBag([[{ serviceStarted: currentTimeRecord, serviceType }, 1n]]),
);
const want = { Items: amountObject };

sum(bagCounts(want.Items.value)) <= maxItems ||
Fail`max ${q(maxItems)} items allowed: ${q(want.Items)}`;
const newSubscription = itemMint.mintGains(want);

const newItems = itemMint.mintGains(want);
atomicRearrange(
zcf,
harden([
// price from buyer to proceeds
[buyerSeat, proceeds, { Price: tradePrice }],
[buyerSeat, proceeds, { Price: subscriptionPrice }],
// new items to buyer
[newItems, buyerSeat, want],
[newSubscription, buyerSeat, want],
]),
);

subscriptions.set(userAddress, want.Items);

buyerSeat.exit(true);
newItems.exit();
return 'trade complete';
newSubscription.exit();
return 'Subscription Granted';
};

/**
Expand All @@ -130,11 +132,42 @@
* - want: `Items`
*/
const makeTradeInvitation = () =>
zcf.makeInvitation(tradeHandler, 'buy items', undefined, proposalShape);
zcf.makeInvitation(
tradeHandler,
'buy subscription',
undefined,
proposalShape,
);

const isSubscriptionValid = userSubscription => {
if (!userSubscription || !userSubscription.value.payload) return false;

const serviceStarted = userSubscription.value.payload[0][0].serviceStarted;

// Here we'll check with current time from time service.
if (!serviceStarted || serviceStarted !== '123') return false;
return true;
//
};

const getSubscriptionResources = userAddress => {
const userSubscription = subscriptions.get(userAddress);

const isValidSub = isSubscriptionValid(userSubscription);
if (isValidSub) {
// User has a valid subscription, return the resources
const serviceType = userSubscription.value.payload[0][0].serviceType;
return subscriptionResources[serviceType];
} else {
// User doesn't have a valid subscription
return 'Access denied: You do not have a valid subscription.';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@samsiegart what was the hackathon project you worked on where services would look for NFTs in wallets (using vstorage queries) to gate access?

Ah... right... https://github.com/agoric-labs/agoric-passport-express

}
};

// Mark the publicFacet Far, i.e. reachable from outside the contract
const publicFacet = Far('Items Public Facet', {
makeTradeInvitation,
getSubscriptionResources,
});
return harden({ publicFacet });
};
Expand Down
64 changes: 44 additions & 20 deletions contract/test/test-contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

/* eslint-disable import/order -- https://github.com/endojs/endo/issues/1235 */
import { test as anyTest } from './prepare-test-env-ava.js';

import buildZoeManualTimer from '@agoric/zoe/tools/manualTimer.js';
import { createRequire } from 'module';
import { E, Far } from '@endo/far';
import { makePromiseKit } from '@endo/promise-kit';
Expand All @@ -26,7 +26,6 @@ const contractPath = myRequire.resolve(`../src/offer-up.contract.js`);
const test = anyTest;

const UNIT6 = 1_000_000n;
const CENT = UNIT6 / 100n;

/**
* Tests assume access to the zoe service and that contracts are bundled.
Expand Down Expand Up @@ -63,7 +62,12 @@ test('Start the contract', async t => {

const money = makeIssuerKit('PlayMoney');
const issuers = { Price: money.issuer };
const terms = { tradePrice: AmountMath.make(money.brand, 5n) };
const timer = buildZoeManualTimer();
const terms = {
subscriptionPrice: AmountMath.make(money.brand, 10000000n),
timerService: timer,
};

t.log('terms:', terms);

/** @type {ERef<Installation<AssetContractFn>>} */
Expand All @@ -78,51 +82,70 @@ test('Start the contract', async t => {
*
* @param {import('ava').ExecutionContext} t
* @param {ZoeService} zoe
* @param {ERef<import('@agoric/zoe/src/zoeService/utils').Instance<AssetContractFn>} instance
* @param {ERef<import('@agoric/zoe/src/zoeService/utils').Instance<AssetContractFn>>} instance
* @param {Purse} purse
* @param {string[]} choices
*/
const alice = async (t, zoe, instance, purse, choices = ['map', 'scroll']) => {
const alice = async (t, zoe, instance, purse) => {
const publicFacet = E(zoe).getPublicFacet(instance);
// @ts-expect-error Promise<Instance> seems to work
const terms = await E(zoe).getTerms(instance);
const { issuers, brands, tradePrice } = terms;
const { issuers, brands, subscriptionPrice, timerService } = terms;

const currentTimeRecord = await E(timerService).getCurrentTimestamp();
const serviceType = 'Netflix';
const choiceBag = makeCopyBag([
[{ serviceStarted: currentTimeRecord, serviceType }, 1n],
]);

const choiceBag = makeCopyBag(choices.map(name => [name, 1n]));
const proposal = {
give: { Price: tradePrice },
give: { Price: subscriptionPrice },
want: { Items: AmountMath.make(brands.Item, choiceBag) },
};
const pmt = await E(purse).withdraw(tradePrice);

const pmt = await E(purse).withdraw(subscriptionPrice);
t.log('Alice gives', proposal.give);
// #endregion makeProposal

const toTrade = E(publicFacet).makeTradeInvitation();

const seat = E(zoe).offer(toTrade, proposal, { Price: pmt });
const userAddress = 'agoric123456';
const seat = E(zoe).offer(
toTrade,
proposal,
{ Price: pmt },
{ userAddress, serviceType },
);
const items = await E(seat).getPayout('Items');

const actual = await E(issuers.Item).getAmountOf(items);
t.log('Alice payout brand', actual.brand);
t.log('Alice payout value', actual.value);
t.deepEqual(actual, proposal.want.Items);

const actualMovies = [`${serviceType}_Movie_1`, `${serviceType}_Movie_2`];
const subscriptionMovies =
await E(publicFacet).getSubscriptionResources(userAddress);

t.deepEqual(actualMovies, subscriptionMovies);
};

test('Alice trades: give some play money, want items', async t => {
test('Alice trades: give some play money, want subscription', async t => {
const { zoe, bundle } = t.context;

const money = makeIssuerKit('PlayMoney');
const issuers = { Price: money.issuer };
const terms = { tradePrice: AmountMath.make(money.brand, 5n) };

const timer = buildZoeManualTimer();
const terms = {
subscriptionPrice: AmountMath.make(money.brand, 10000000n),
timerService: timer,
};
/** @type {ERef<Installation<AssetContractFn>>} */
const installation = E(zoe).install(bundle);
const { instance } = await E(zoe).startInstance(installation, issuers, terms);
t.log(instance);
t.is(typeof instance, 'object');

const alicePurse = money.issuer.makeEmptyPurse();
const amountOfMoney = AmountMath.make(money.brand, 10n);
const amountOfMoney = AmountMath.make(money.brand, 10000000n);
const moneyPayment = money.mint.mintPayment(amountOfMoney);
alicePurse.deposit(moneyPayment);
await alice(t, zoe, instance, alicePurse);
Expand All @@ -140,18 +163,19 @@ test('Trade in IST rather than play money', async t => {
const installation = E(zoe).install(bundle);
const feeIssuer = await E(zoe).getFeeIssuer();
const feeBrand = await E(feeIssuer).getBrand();
const tradePrice = AmountMath.make(feeBrand, 25n * CENT);
const subscriptionPrice = AmountMath.make(feeBrand, 10000000n);
const timer = buildZoeManualTimer();
return E(zoe).startInstance(
installation,
{ Price: feeIssuer },
{ tradePrice },
{ subscriptionPrice, timerService: timer },
);
};

const { zoe, bundle, bundleCache, feeMintAccess } = t.context;
const { instance } = await startContract({ zoe, bundle });
const { faucet } = makeStableFaucet({ bundleCache, feeMintAccess, zoe });
await alice(t, zoe, instance, await faucet(5n * UNIT6));
await alice(t, zoe, instance, await faucet(10n * UNIT6));
});

test('use the code that will go on chain to start the contract', async t => {
Expand Down Expand Up @@ -225,5 +249,5 @@ test('use the code that will go on chain to start the contract', async t => {
// Now that we have the instance, resume testing as above.
const { feeMintAccess, bundleCache } = t.context;
const { faucet } = makeStableFaucet({ bundleCache, feeMintAccess, zoe });
await alice(t, zoe, instance, await faucet(5n * UNIT6));
await alice(t, zoe, instance, await faucet(10n * UNIT6));
});
Loading
Loading