Skip to content

Commit

Permalink
feat(feeDistributor): new run-protocol Zoe contract
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelfig committed May 27, 2022
1 parent 85d2968 commit b5d9869
Show file tree
Hide file tree
Showing 13 changed files with 452 additions and 172 deletions.
4 changes: 4 additions & 0 deletions packages/run-protocol/scripts/init-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ const installKeyGroups = {
'../src/vaultFactory/vaultFactory.js',
'../bundles/bundle-vaultFactory.js',
],
feeDistributor: [
'../src/feeDistributor.js',
'../bundles/bundle-feeDistributor.js',
],
liquidateMinimum: [
'../src/vaultFactory/liquidateMinimum.js',
'../bundles/bundle-liquidateMinimum.js',
Expand Down
311 changes: 311 additions & 0 deletions packages/run-protocol/src/feeDistributor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
// @ts-check

import { AmountMath } from '@agoric/ertp';
import { E, Far } from '@endo/far';
import { observeNotifier } from '@agoric/notifier';
import { makeScalarSetStore } from '@agoric/store';

/**
* @typedef {object} FeeCollector
*
* @property {() => ERef<Payment>} collectFees
*/

/**
* @typedef {object} PeriodicFeeCollector
* @property {() => FeeCollector} getCollector
* @property {() => Promise<void>} collectAndDistributeNow
* @property {() => void} stop
*/

/**
* @typedef {object} CollectibleContractFacet
* @property {() => Promise<Invitation>} makeCollectFeesInvitation
*/

/**
* wrapper to take the vaultFactory or AMM's creatorFacet, and make a call that
* will request an invitation and return a promise for a payment.
*
* @param {ERef<ZoeService>} zoe
* @param {ERef<CollectibleContractFacet>} creatorFacet
*/
export const makeContractFeeCollector = (zoe, creatorFacet) => {
/** @type {FeeCollector} */
return Far('feeCollector', {
collectFees: () => {
const invitation = E(creatorFacet).makeCollectFeesInvitation();
const collectFeesSeat = E(zoe).offer(invitation, undefined, undefined);
return E(collectFeesSeat).getPayout('RUN');
},
});
};

/**
* A distributor of fees from Inter Protocol sources to deposit facets. Each
* time the epochTimer signals the end of an Epoch, it will ask the contracts
* for fees that have been collected to date and send that payment to the
* depositFacet.
*
* @param {() => Promise<unknown>} schedulePayments - distribute to the destinations
* @param {ERef<TimerService>} timerService - timer that is used to schedule collections
* @param {RelativeTime} [collectionInterval] - how often to collect fees in the
* `timerService` unit
*/
export const startDistributing = (
schedulePayments,
timerService,
collectionInterval = 1n,
) => {
const timeObserver = {
updateState: _ =>
schedulePayments().catch(e => {
console.error('failed with', e);
throw e;
}),
fail: reason => {
throw Error(`fee distributor timer failed: ${reason}`);
},
finish: done => {
throw Error(`fee distributor timer died: ${done}`);
},
};

const collectionNotifier = E(timerService).makeNotifier(
0n,
collectionInterval,
);
observeNotifier(collectionNotifier, timeObserver).catch(e => {
console.error('fee distributor failed with', e);
});
};

/**
* @typedef {{ pushPayment: (payment: Payment, issuer: ERef<Issuer>) => Promise<Amount>}} FeeDestination
*
* @param {Record<Keyword, ERef<FeeDestination>>} [destinations]
* @param {Record<Keyword, bigint>} [keywordShares]
*/
export const makeShareConfig = (destinations = {}, keywordShares = {}) => {
let totalShares = 0n;
const shares = Object.entries(destinations)
.filter(([_, dst]) => dst)
.map(([kw, destination]) => {
const share = keywordShares[kw];
assert.typeof(share, 'bigint', `${kw} must be a bigint; got ${share}`);
totalShares += share;
return {
share,
destination,
};
});
return { shares, totalShares };
};

/** @typedef {ReturnType<typeof makeShareConfig>} ShareConfig */

/**
* Split and deposit a payment given a share configuration.
*
* @param {Payment} payment
* @param {ERef<Issuer>} issuer
* @param {ShareConfig} shareConfig
*/
export const sharePayment = async (
payment,
issuer,
{ shares, totalShares },
) => {
// Divide the payment into shares.
if (!shares.length) {
return;
}

/** @type {Amount<'nat'>} */
const paymentAmount = await E(issuer).getAmountOf(payment);
if (AmountMath.isEmpty(paymentAmount)) {
return;
}
const { value: totalValue, brand } = paymentAmount;

let remainder = totalValue;
const destinedAmountEntries = shares
.map(({ share, destination }, i) => {
const value =
i === shares.length - 1
? remainder
: (totalValue * share) / totalShares;
remainder -= value;
return /** @type {const} */ ([
destination,
AmountMath.make(brand, value),
]);
})
.filter(([_, amt]) => !AmountMath.isEmpty(amt));

// Split the payment, which asserts conservation of the total.
const sharedPayment = await E(issuer).splitMany(
payment,
destinedAmountEntries.map(([_dst, amt]) => amt),
);

// Send each nonempty payment to the corresponding destination.
await Promise.all(
destinedAmountEntries.map(([destination], i) =>
E(destination).pushPayment(sharedPayment[i], issuer),
),
);
};

/**
* @param {ERef<Issuer>} feeIssuer
* @param {{ keywordShares: Record<Keyword, bigint>, timerService: ERef<TimerService>, collectionInterval: RelativeTime}} terms
*/
export const makeFeeDistributor = (feeIssuer, terms) => {
const { timerService, collectionInterval } = terms;

/** @type {Record<string, ERef<FeeDestination>>} */
let destinations = {};
let shareConfig = makeShareConfig(destinations, terms.keywordShares);

/** @type {SetStore<FeeCollector>} */
const collectors = makeScalarSetStore();

/** @param {Payment} payment */
const distributeFees = payment =>
sharePayment(payment, feeIssuer, shareConfig);

const schedulePayments = async () => {
if (shareConfig.totalShares <= 0n) {
return;
}
await Promise.all(
[...collectors.values()].map(collector =>
E(collector).collectFees().then(distributeFees),
),
);
};

const publicFacet = Far('feeDistributor publicFacet', {
distributeFees,
});

const creatorFacet = Far('feeDistributor creatorFacet', {
makeContractFeeCollector,
/**
* Start distributing fees from this collector.
*
* @param {string} debugName
* @param {ERef<FeeCollector>} collectorP
*/
startPeriodicCollection: async (debugName, collectorP) => {
const collector = await collectorP;

/** @type {PeriodicFeeCollector} */
const periodicCollector = Far('PeriodicFeeCollector', {
getDebugName: () => debugName,
getCollector: () => collector,
collectAndDistributeNow: async () => {
if (shareConfig.totalShares <= 0n) {
return;
}
const payment = await E(collector).collectFees();
await distributeFees(payment);
},
stop: () => {
collectors.delete(collector);
},
});

// Run once immediately for this collector, to test that the registration
// will work.
await E(periodicCollector).collectAndDistributeNow();

collectors.add(collector);
return periodicCollector;
},

/**
* @param {EOnly<DepositFacet>} depositFacet
*/
makeDepositFacetDestination: depositFacet => {
return Far(`DepositFacetDestination`, {
pushPayment: async (payment, _issuer) => {
return E(depositFacet).receive(payment);
},
});
},
/**
* Create a destination that generates invitations and makes Zoe offers.
*
* @param {ERef<ZoeService>} zoe
* @param {string} keyword
* @param {unknown} target
* @param {PropertyKey} makeInvitationMethod
* @param {unknown[]} [args]
*/
makeOfferDestination: (
zoe,
keyword,
target,
makeInvitationMethod,
args = [],
) => {
return Far(`${String(makeInvitationMethod)} OfferDestination`, {
pushPayment: async (payment, issuer) => {
const paymentAmount = await E(issuer).getAmountOf(payment);

// Give the payment to the contract via its invitation.
const invitation = E(target)[makeInvitationMethod](...args);
const result = E(zoe).offer(
invitation,
{
give: {
[keyword]: paymentAmount,
},
},
{
[keyword]: payment,
},
);

// Assert that the offer completed.
await E(result).getOfferResult();

// We deliberately drop our payouts on the floor, since the ERTP purse
// recovery mechanism can get them back to the distributor contract.
return paymentAmount;
},
});
},

/**
* @param {Record<Keyword, FeeDestination>} newDestinations
*/
setDestinations: async newDestinations => {
destinations = newDestinations;
shareConfig = makeShareConfig(destinations, terms.keywordShares);
// Run once immediately for these destinations.
await schedulePayments();
},
});

// Start processing collections.
startDistributing(schedulePayments, timerService, collectionInterval);

return harden({
creatorFacet,
publicFacet,
});
};

/** @typedef {ReturnType<typeof makeFeeDistributor>['creatorFacet']} FeeDistributorCreatorFacet */
/** @typedef {ReturnType<typeof makeFeeDistributor>['publicFacet']} FeeDistributorPublicFacet */

/**
* @param {ZCF<Parameters<typeof makeFeeDistributor>[1]>} zcf
*/
export const start = async zcf => {
const feeIssuer = E(zcf.getZoeService()).getFeeIssuer();
return makeFeeDistributor(feeIssuer, zcf.getTerms());
};
7 changes: 6 additions & 1 deletion packages/run-protocol/src/proposals/core-proposal.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,16 @@ const REWARD_MANIFEST = harden({
consume: {
chainTimerService: true,
bankManager: true,
loadVat: true,
vaultFactoryCreator: true,
periodicFeeCollectors: true,
ammCreatorFacet: true,
runStakeCreatorFacet: true,
reservePublicFacet: true,
zoe: true,
},
produce: { feeDistributorCreatorFacet: true, periodicFeeCollectors: true },
instance: { produce: { feeDistributor: true } },
installation: { consume: { feeDistributor: true } },
issuer: { consume: { RUN: 'zoe' } },
brand: { consume: { RUN: 'zoe' } },
},
Expand Down Expand Up @@ -268,6 +272,7 @@ export const getManifestForMain = (
installations: {
amm: restoreRef(installKeys.amm),
VaultFactory: restoreRef(installKeys.vaultFactory),
feeDistributor: restoreRef(installKeys.feeDistributor),
liquidate: restoreRef(installKeys.liquidate),
reserve: restoreRef(installKeys.reserve),
interchainPool: restoreRef(installKeys.interchainPool),
Expand Down
Loading

0 comments on commit b5d9869

Please sign in to comment.