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

feat(fast-usdc): deposit, withdraw liquidity in exchange for shares #10400

Merged
merged 3 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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)}`;
};
Comment on lines +49 to +54
Copy link
Member

Choose a reason for hiding this comment

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

  1. Consider assertPoolBalance
  2. Can you talk more about what this invariant check is doing? How will it work when an advance is in flight?

Copy link
Member Author

Choose a reason for hiding this comment

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

Consider assertPoolBalance

assertFoo(x) usually means: assert that x is a Foo.

Can you talk more about what this invariant check is doing?

The atomicRearranges that follow the call to depositCalc / withdrawCalc assume that the pool balance represented by the poolSeat USDC allocation is the same as the pool balance represented by the numerator of shareWorth. When I first added this check, my tests failed; they differ by the dust used to make the initial shareworth denominator non-zero.

The fact that this answer didn't come in the form of a pointer to existing docs says I should add more.

How will it work when an advance is in flight?

In flight in what sense? Its only use is in normal straight-line synchronous offer handlers: gather all the relevant info in one place and compute the outcome, synchronously. Things happening outside this vat may happen causally before or after, or in an incomparable causal order. In any case, the local computation does what it does.


/**
* @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) => {
turadg marked this conversation as resolved.
Show resolved Hide resolved
const { brand: PoolShares } = shareMint.getIssuerRecord();
Copy link
Member

Choose a reason for hiding this comment

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

nit: should we call it PoolShare? Or even PS? I've gotten really used to reading brands as 3-4 uppercase letters.

Copy link
Member Author

Choose a reason for hiding this comment

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

hardly a nit! the keys of agoricNames.brand are of tremendous consequence.

I'll follow up with product.
cc @sufyaankhan

Copy link
Member Author

Choose a reason for hiding this comment

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

I suppose the name here is local to this contract. But I'm also working on the core-eval (#10301), which will put a name in agoricNames.brand.

Copy link
Member

Choose a reason for hiding this comment

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

I've gotten really used to reading brands as 3-4 uppercase letters

Here's the current list of brands in agoricNames,

ATOM
BLD
DAI_axl
IST
Invitation
KREAdCHARACTER
USDC
USDC_axl
USDC_grv
USDT_axl
USDT_grv
stATOM
stTIA
timer
stkATOM

I think the abbreviations are generally for vbank brands. Using the more verbose style would be appropriate here, in part to convey it's not a vbank brand.

Copy link
Member Author

Choose a reason for hiding this comment

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

not a vbank brand? why not? we don't want folks sending it over IBC to trade on the open market for some reason?

Copy link
Member

Choose a reason for hiding this comment

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

I think we punted on it being a vbank brand, but with the option to make it one later. That suggests that the name be amenable to vbank.

But ay you also point out, it's easy to change later. We can defer to milestone 3: #10432

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.
Copy link
Member

Choose a reason for hiding this comment

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

helpful comment!

// 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
turadg marked this conversation as resolved.
Show resolved Hide resolved

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
Copy link
Member

Choose a reason for hiding this comment

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

🙏

[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() {
Copy link
Member

Choose a reason for hiding this comment

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

I was surprised to see getPublicTopics on this Exo until I saw that this publicFacet is the publicFacet of the contract.

Consider making this public facet be its own zone.exo in the contract that calls out to a closely held LiquidityPool exo.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good idea, but after looking into it, I'd like to postpone this until we get more pieces of the contract together.

I started on it, but the LP exo isn't available until after remote calls, which would mean doing the same gymnastics for the public facet.

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'),
turadg marked this conversation as resolved.
Show resolved Hide resolved
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) {
Copy link
Member

Choose a reason for hiding this comment

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

might be worth a louder call to remove

Copy link
Member

Choose a reason for hiding this comment

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

What will the interfaces be for the advancer? Would be great to see them here. It will need to:

  • withdraw a payment from a purse
  • return principal to pool (advanced funds)
  • return a fee

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
Loading