Skip to content

Commit

Permalink
feat(fast-usdc): deposit, withdraw liquidity in exchange for shares (#…
Browse files Browse the repository at this point in the history
…10400)

closes: #10386

## Description

 - math module with unit tests
 - exo with precious state (including shareMint)
 - public facet methods to make, handle invitations

 - postponed: representing the pool as an ICA

### Security Considerations

 - integrity: the contract could issue more shares than a liquidity provider paid for, or otherwise allow them to get more USDC out than they are due
 - availability: the contract could fail to redeem shares for sufficient USDC liquidity

TODO:
 - [x] exo interface guards

### Scaling Considerations

state is O(1), as is compute, I'm pretty sure.

### Documentation Considerations

proposal shapes should help clients make offers.

There are no offerArgs nor invitationArgs.

Note that the `PoolShare` brand is now created by the contract, as part of a `ZCFMint`, not passed to `startInstance` as part of the issuerKeywordRecord.

### Testing Considerations

Consistent with plans to do multi-vat swingset tests later, we have:

 1. unit tests for the math
 2. basic contract tests with a mock zoe

### Upgrade Considerations

The liquidity pool exo (and the public facet) are designed to survive upgrade; _testing this is planned for later_.
  • Loading branch information
mergify[bot] authored Nov 13, 2024
2 parents 40e0e4e + 5ae543d commit 324bca1
Show file tree
Hide file tree
Showing 13 changed files with 1,023 additions and 7 deletions.
6 changes: 5 additions & 1 deletion packages/ERTP/src/typeGuards.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// @jessie-check

import { M, matches, getInterfaceGuardPayload } from '@endo/patterns';
/** @import {AmountValue, AssetKindForValue, AssetValueForKind, Brand, MathHelpers} from './types.js' */
/**
* @import {AmountValue, Ratio} from './types.js'
* @import {TypedPattern} from '@agoric/internal'
*/

export const BrandShape = M.remotable('Brand');
export const IssuerShape = M.remotable('Issuer');
Expand Down Expand Up @@ -90,6 +93,7 @@ export const AmountShape = harden({
*/
export const AmountPatternShape = M.pattern();

/** @type {TypedPattern<Ratio>} */
export const RatioShape = harden({
numerator: AmountShape,
denominator: AmountShape,
Expand Down
3 changes: 3 additions & 0 deletions packages/ERTP/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ export type AssetKindForValue<V extends AmountValue> = V extends NatValue
: V extends import('@endo/patterns').CopyBag
? 'copyBag'
: never;

export type Ratio = { numerator: Amount<'nat'>; denominator: Amount<'nat'> };

/** @deprecated */
export type DisplayInfo<K extends AssetKind = AssetKind> = {
/**
Expand Down
3 changes: 2 additions & 1 deletion packages/fast-usdc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
"devDependencies": {
"@agoric/swingset-liveslots": "^0.10.2",
"@agoric/vats": "^0.15.1",
"@agoric/zoe": "^0.26.2",
"@agoric/zone": "^0.2.2",
"@fast-check/ava": "^2.0.1",
"ava": "^5.3.0",
"c8": "^9.1.0",
"ts-blank-space": "^0.4.1"
Expand All @@ -37,6 +37,7 @@
"@agoric/orchestration": "^0.1.0",
"@agoric/store": "^0.9.2",
"@agoric/vow": "^0.1.0",
"@agoric/zoe": "^0.26.2",
"@endo/base64": "^1.0.8",
"@endo/common": "^1.2.7",
"@endo/errors": "^1.2.7",
Expand Down
239 changes: 239 additions & 0 deletions packages/fast-usdc/src/exos/liquidity-pool.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import {
AmountMath,
AmountShape,
PaymentShape,
RatioShape,
} from '@agoric/ertp';
import {
makeRecorderTopic,
TopicsRecordShape,
} from '@agoric/zoe/src/contractSupport/topics.js';
import { depositToSeat } from '@agoric/zoe/src/contractSupport/zoeHelpers.js';
import { SeatShape } from '@agoric/zoe/src/typeGuards.js';
import { M } from '@endo/patterns';
import { Fail, q } from '@endo/errors';
import {
depositCalc,
makeParity,
withdrawCalc,
withFees,
} from '../pool-share-math.js';
import { makeProposalShapes } from '../type-guards.js';

/**
* @import {Zone} from '@agoric/zone';
* @import {Remote, TypedPattern} from '@agoric/internal'
* @import {StorageNode} from '@agoric/internal/src/lib-chainStorage.js'
* @import {MakeRecorderKit, RecorderKit} from '@agoric/zoe/src/contractSupport/recorder.js'
* @import {USDCProposalShapes, ShareWorth} from '../pool-share-math.js'
*/

const { add, isEqual } = AmountMath;

/** @param {Brand} brand */
const makeDust = brand => AmountMath.make(brand, 1n);

/**
* Use of pool-share-math in offer handlers below assumes that
* the pool balance represented by the USDC allocation in poolSeat
* is the same as the pool balance represented by the numerator
* of shareWorth.
*
* Well, almost: they're the same modulo the dust used
* to initialize shareWorth with a non-zero denominator.
*
* @param {ZCFSeat} poolSeat
* @param {ShareWorth} shareWorth
* @param {Brand} USDC
*/
const checkPoolBalance = (poolSeat, shareWorth, USDC) => {
const available = poolSeat.getAmountAllocated('USDC', USDC);
const dust = makeDust(USDC);
isEqual(add(available, dust), shareWorth.numerator) ||
Fail`🚨 pool balance ${q(available)} inconsistent with shareWorth ${q(shareWorth)}`;
};

/**
* @param {Zone} zone
* @param {ZCF} zcf
* @param {Brand<'nat'>} USDC
* @param {{
* makeRecorderKit: MakeRecorderKit;
* }} tools
*/
export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => {
return zone.exoClassKit(
'Liquidity Pool',
{
feeSink: M.interface('feeSink', {
receive: M.call(AmountShape, PaymentShape).returns(M.promise()),
}),
external: M.interface('external', {
publishShareWorth: M.call().returns(),
}),
depositHandler: M.interface('depositHandler', {
handle: M.call(SeatShape, M.any()).returns(M.promise()),
}),
withdrawHandler: M.interface('withdrawHandler', {
handle: M.call(SeatShape, M.any()).returns(M.promise()),
}),
public: M.interface('public', {
makeDepositInvitation: M.call().returns(M.promise()),
makeWithdrawInvitation: M.call().returns(M.promise()),
getPublicTopics: M.call().returns(TopicsRecordShape),
}),
},
/**
* @param {ZCFMint<'nat'>} shareMint
* @param {Remote<StorageNode>} node
*/
(shareMint, node) => {
const { brand: PoolShares } = shareMint.getIssuerRecord();
const proposalShapes = makeProposalShapes({ USDC, PoolShares });
const shareWorth = makeParity(makeDust(USDC), PoolShares);
const { zcfSeat: poolSeat } = zcf.makeEmptySeatKit();
const shareWorthRecorderKit = tools.makeRecorderKit(node, RatioShape);
return {
shareMint,
shareWorth,
poolSeat,
PoolShares,
proposalShapes,
shareWorthRecorderKit,
};
},
{
feeSink: {
/**
* @param {Amount<'nat'>} amount
* @param {Payment<'nat'>} payment
*/
async receive(amount, payment) {
const { poolSeat, shareWorth } = this.state;
const { external } = this.facets;
await depositToSeat(
zcf,
poolSeat,
harden({ USDC: amount }),
harden({ USDC: payment }),
);
this.state.shareWorth = withFees(shareWorth, amount);
external.publishShareWorth();
},
},

external: {
publishShareWorth() {
const { shareWorth } = this.state;
const { recorder } = this.state.shareWorthRecorderKit;
// Consumers of this .write() are off-chain / outside the VM.
// And there's no way to recover from a failed write.
// So don't await.
void recorder.write(shareWorth);
},
},

depositHandler: {
/** @param {ZCFSeat} lp */
async handle(lp) {
const { shareWorth, shareMint, poolSeat } = this.state;
const { external } = this.facets;

/** @type {USDCProposalShapes['deposit']} */
// @ts-expect-error ensured by proposalShape
const proposal = lp.getProposal();
checkPoolBalance(poolSeat, shareWorth, USDC);
const post = depositCalc(shareWorth, proposal);

// COMMIT POINT

try {
const mint = shareMint.mintGains(post.payouts);
this.state.shareWorth = post.shareWorth;
zcf.atomicRearrange(
harden([
// zoe guarantees lp has proposal.give allocated
[lp, poolSeat, proposal.give],
// mintGains() above establishes that mint has post.payouts
[mint, lp, post.payouts],
]),
);
lp.exit();
mint.exit();
} catch (cause) {
const reason = Error('🚨 cannot commit deposit', { cause });
console.error(reason.message, cause);
zcf.shutdownWithFailure(reason);
}
external.publishShareWorth();
},
},
withdrawHandler: {
/** @param {ZCFSeat} lp */
async handle(lp) {
const { shareWorth, shareMint, poolSeat } = this.state;
const { external } = this.facets;

/** @type {USDCProposalShapes['withdraw']} */
// @ts-expect-error ensured by proposalShape
const proposal = lp.getProposal();
const { zcfSeat: burn } = zcf.makeEmptySeatKit();
checkPoolBalance(poolSeat, shareWorth, USDC);
const post = withdrawCalc(shareWorth, proposal);

// COMMIT POINT

try {
this.state.shareWorth = post.shareWorth;
zcf.atomicRearrange(
harden([
// zoe guarantees lp has proposal.give allocated
[lp, burn, proposal.give],
// checkPoolBalance() + withdrawCalc() guarantee poolSeat has enough
[poolSeat, lp, post.payouts],
]),
);
shareMint.burnLosses(proposal.give, burn);
lp.exit();
burn.exit();
} catch (cause) {
const reason = Error('🚨 cannot commit withdraw', { cause });
console.error(reason.message, cause);
zcf.shutdownWithFailure(reason);
}
external.publishShareWorth();
},
},
public: {
makeDepositInvitation() {
return zcf.makeInvitation(
this.facets.depositHandler,
'Deposit',
undefined,
this.state.proposalShapes.deposit,
);
},
makeWithdrawInvitation() {
return zcf.makeInvitation(
this.facets.withdrawHandler,
'Withdraw',
undefined,
this.state.proposalShapes.withdraw,
);
},
getPublicTopics() {
const { shareWorthRecorderKit } = this.state;
return {
shareWorth: makeRecorderTopic('shareWorth', shareWorthRecorderKit),
};
},
},
},
{
finish: ({ facets: { external } }) => {
void external.publishShareWorth();
},
},
);
};
harden(prepareLiquidityPoolKit);
62 changes: 60 additions & 2 deletions packages/fast-usdc/src/fast-usdc.contract.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { AssetKind } from '@agoric/ertp';
import { BrandShape } from '@agoric/ertp/src/typeGuards.js';
import { assertAllDefined, makeTracer } from '@agoric/internal';
import { observeIteration, subscribeEach } from '@agoric/notifier';
import { withOrchestration } from '@agoric/orchestration';
import { provideSingleton } from '@agoric/zoe/src/contractSupport/durability.js';
import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js';
import { M } from '@endo/patterns';
import { prepareAdvancer } from './exos/advancer.js';
import { prepareLiquidityPoolKit } from './exos/liquidity-pool.js';
import { prepareSettler } from './exos/settler.js';
import { prepareStatusManager } from './exos/status-manager.js';
import { prepareTransactionFeedKit } from './exos/transaction-feed.js';
Expand Down Expand Up @@ -43,7 +47,11 @@ export const contract = async (zcf, privateArgs, zone, tools) => {
assert(tools, 'no tools');
const terms = zcf.getTerms();
assert('USDC' in terms.brands, 'no USDC brand');
assert('PoolShares' in terms.brands, 'no PoolShares brand');

const { makeRecorderKit } = prepareRecorderKitMakers(
zone.mapStore('vstorage'),
privateArgs.marshaller,
);

const statusManager = prepareStatusManager(zone);
const makeSettler = prepareSettler(zone, { statusManager });
Expand Down Expand Up @@ -71,8 +79,20 @@ export const contract = async (zcf, privateArgs, zone, tools) => {
}
},
});
const makeLiquidityPoolKit = prepareLiquidityPoolKit(
zone,
zcf,
terms.brands.USDC,
{ makeRecorderKit },
);

const creatorFacet = zone.exo('Fast USDC Creator', undefined, {});
const creatorFacet = zone.exo('Fast USDC Creator', undefined, {
simulateFeesFromAdvance(amount, payment) {
console.log('🚧🚧 UNTIL: advance fees are implemented 🚧🚧');
// eslint-disable-next-line no-use-before-define
return poolKit.feeSink.receive(amount, payment);
},
});

const publicFacet = zone.exo('Fast USDC Public', undefined, {
// XXX to be removed before production
Expand All @@ -95,8 +115,46 @@ export const contract = async (zcf, privateArgs, zone, tools) => {
return 'noop; evidence was pushed in the invitation maker call';
}, 'noop invitation');
},
makeDepositInvitation() {
// eslint-disable-next-line no-use-before-define
return poolKit.public.makeDepositInvitation();
},
makeWithdrawInvitation() {
// eslint-disable-next-line no-use-before-define
return poolKit.public.makeWithdrawInvitation();
},
getPublicTopics() {
// eslint-disable-next-line no-use-before-define
return poolKit.public.getPublicTopics();
},
});

// ^^^ Define all kinds above this line. Keep remote calls below. vvv

// NOTE: Using a ZCFMint is helpful for the usual reasons (
// synchronous mint/burn, keeping assets out of contract vats, ...).
// And there's just one pool, which suggests building it with zone.exo().
//
// But zone.exo() defines a kind and
// all kinds have to be defined before any remote calls,
// such as the one to the zoe vat as part of making a ZCFMint.
//
// So we use zone.exoClassKit above to define the liquidity pool kind
// and pass the shareMint into the maker / init function.

const shareMint = await provideSingleton(
zone.mapStore('mint'),
'PoolShare',
() =>
zcf.makeZCFMint('PoolShares', AssetKind.NAT, {
decimalPlaces: 6,
}),
);

const poolKit = zone.makeOnce('Liquidity Pool kit', () =>
makeLiquidityPoolKit(shareMint, privateArgs.storageNode),
);

return harden({ creatorFacet, publicFacet });
};
harden(contract);
Expand Down
Loading

0 comments on commit 324bca1

Please sign in to comment.