-
Notifications
You must be signed in to change notification settings - Fork 207
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
Conversation
idea: I think the |
ea8a352
to
9c389e2
Compare
51f7c6d
to
300c524
Compare
{ | ||
const proposal = harden({ | ||
give: { USDC: usdc.make(100n) }, | ||
want: { PoolShare: make(PoolShares, 20n) }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I assume a real client would need to:
- Read
shareWorth
from the contract somehow. - Use pool-share-math.js to calculate the expected number of shares.
Does that sound realistic? If so, could we add a TODO or modify the test to follow that model more?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. Thanks for the reminder, in fact. I was thinking about this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That prompted me to flesh out a bunch of publish/subscribe stuff (83ed6c8). I like the result, though; I enhanced the story-telling aspect of the test while I was at it:
✔ LP deposits, earns fees, withdraws (1.6s)
ℹ bootstrap vat dependencies
ℹ Alice deposits 60 USDC
ℹ Bob deposits 40 USDC
ℹ Alice deposit payout 60 PoolShares
ℹ Bob deposit payout 40 PoolShares
ℹ contract accrues some amount of fees: 25 USDC
ℹ Alice sees fees earned 15 USDC
ℹ Alice withdraws 20 %: 12 PoolShares
ℹ Bob sees fees earned 10 USDC
ℹ Bob withdraws 80 %: 32 PoolShares
ℹ Alice withdaw payout 15 USDC
ℹ Bob withdaw payout 40 USDC
I didn't show shareWorth
in the t.log(...)
story, but fetching it is necessary for "Alice sees fees earned 15 USDC", which is explicit in the product spec.
To calculate the expected number of shares, divideBy(give.USDC, shareWorth)
suffices. No need for anything special from pool-share-math.js
.
I didn't bother to handle slippage here. I suppose pool-share-math.js
could be enhanced to support that, though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah yea this seems more like it!
No need for anything special from pool-share-math.js.
Ack
I didn't bother to handle slippage here. I suppose pool-share-math.js could be enhanced to support that, though.
Hmm yea that could be tricky. The pool could also fluctuate drastically as it makes advances and receives settlements, so they'd have to call withdraw
at the right time between advancements. Unless we wrote some logic that could put things on hold until the withdrawal completes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, need to audit the math more closely but here's feedback so far.
Aside - a lot of DeFi "Share Math" vulns seem to hinge on the ability to donate funds to the pool. Seems like another +1 for using a seat/purse instead of a (reachable) OrchAccount.
* @param {ZCFMint<'nat'>} shareMint | ||
*/ | ||
shareMint => { | ||
const { brand: PoolShares } = shareMint.getIssuerRecord(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
export const makeProposalShapes = ({ PoolShares, USDC }) => { | ||
/** @type {TypedPattern<USDCProposalShapes['deposit']>} */ | ||
const deposit = M.splitRecord( | ||
{ give: { USDC: makeNatAmountShape(USDC, 1n) } }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Beneficial to parameterize min
here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm struggling to see any benefit. Help?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(anticipated) product req: user must deposit minimum of 1e6
or 10e6
units, not 1n units, so initial share price is not weird
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
weird how?
note that share worth starts 1-1:
const dust = AmountMath.make(USDC, 1n);
const shareWorth = makeParity(dust, PoolShares);
Other than that, there are no special cases, AFAICT.
'sequence of deposits and withdrawals', | ||
[ | ||
fc.array( | ||
fc.record({ | ||
party: fc.nat(7), | ||
action: fc.oneof( | ||
fc.record({ In: arbUSDC }), | ||
fc.record({ Part: arbPortion, Slip: arbDelta }), | ||
), | ||
}), | ||
{ minLength: 3 }, | ||
), | ||
], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice
const contractFile = `${dirname}/../src/fast-usdc.contract.js`; | ||
type StartFn = typeof import('../src/fast-usdc.contract.js').start; | ||
|
||
const makeTestContext = async () => { | ||
const bundleCache = await makeNodeBundleCache('bundles', {}, s => import(s)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunate the test has this burden. I've filed #10427
* @param {ZCFMint<'nat'>} shareMint | ||
*/ | ||
shareMint => { | ||
const { brand: PoolShares } = shareMint.getIssuerRecord(); |
There was a problem hiding this comment.
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
import { AmountMath } from '@agoric/ertp/src/amountMath.js'; | ||
import { | ||
AmountShape, | ||
PaymentShape, | ||
RatioShape, | ||
} from '@agoric/ertp/src/typeGuards.js'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These can and should be imported from the top level
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah. I'm still suffering from bundle-size PTSD.
It doesn't make any difference in this case. 2.7M bundle size in either case. Something else is grabbing the rest of ERTP, or the difference is less than 1%.
If these imports are in a stand-alone entry point module, there is a noticeable difference (1.1M vs. 732K), but not as much as I expected.
I noticed that ERTP gets M
and matches
from @agoric/store
. Changing that to @endo/patterns
reduces it to 728K.
TopicsRecordShape, | ||
} from '@agoric/zoe/src/contractSupport/topics.js'; | ||
import { | ||
deposit as depositCalc, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is a local module. please name the exports such that they don't need renaming to import
/** | ||
* @param {Zone} zone | ||
* @param {ZCF} zcf | ||
* @param {Record<'USDC', Brand<'nat'>>} brands |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the brands
param reads like it's the source of all brands. since this is just for one brand please just pass it in as a usdcBrand
param, Or if you really want the destructuring something like externalBrands
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oops. yeah. left-over from when a PoolShare
brand was passed in
const { zcfSeat: poolSeat } = zcf.makeEmptySeatKit(); | ||
const shareWorthRecorderKit = tools.makeRecorderKit( | ||
node, | ||
/** @type {TypedPattern<ShareWorth>} */ (RatioShape), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
consider a ShareWorthShape
constant
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since the shape/pattern doesn't help enforce the ShareWorth
invariant, I'm inclined to go the other way: TypedPattern<Ratio>
.
assertAllDefined({ feed, settler, advancer, statusManager }); | ||
|
||
const creatorFacet = zone.exo('Fast USDC Creator', undefined, {}); | ||
const creatorFacet = zone.exo('Fast USDC Creator', undefined, { | ||
simulateFeesFromAdvance(amount, payment) { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
* | ||
* Shares = ToPool * sharesOutstanding / poolBalance | ||
* | ||
* that is: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks for stepping through
|
||
const { denominator: sharesOutstanding, numerator: poolBalance } = shareWorth; | ||
|
||
const PoolShare = divideBy(give.USDC, shareWorth); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please give this a more specific name. e.g.
const PoolShare = divideBy(give.USDC, shareWorth); | |
const fairPoolShare = divideBy(give.USDC, shareWorth); |
then the conditional below reads more clearly,
isGTE(fairPoolShare, want.PoolShare) || Fail
}; | ||
const test: TestFn<Awaited<ReturnType<typeof makeTestContext>>> = anyTest; | ||
|
||
test.before('cache bundles', async t => (t.context = await makeTestContext())); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
consider leaving caching for #10430
const { zcfSeat: poolSeat } = zcf.makeEmptySeatKit(); | ||
const shareWorthRecorderKit = tools.makeRecorderKit( | ||
node, | ||
/** @type {TypedPattern<ShareWorth>} */ (RatioShape), | ||
/** @type {TypedPattern<Ratio>} */ (RatioShape), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please annotate RatioShape
instead
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ratio
is defined in @agoric/zoe
. Adding a (dev)dependency from ERTP to Zoe seems odd.
Does annotating shareWorthRecorderKit
work for you?
harden([ | ||
// zoe guarantees lp has proposal.give allocated | ||
[lp, poolSeat, proposal.give], | ||
// mintGains() above establishes that mint has post.payouts |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🙏
} catch (bug) { | ||
const reason = Error('🚨 cannot commit deposit', { cause: bug }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bug
is cute but a little distracting.
} catch (bug) { | |
const reason = Error('🚨 cannot commit deposit', { cause: bug }); | |
} catch (cause) { | |
const reason = Error('🚨 cannot commit deposit', { cause }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh. I didn't notice the cute part. I was trying to distinguish runtime problems from design-time bugs. but ok.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had some vague notion of that. If you want to communicate that intent to the reader please use a code comment
harden({ USDC: payment }), | ||
); | ||
this.state.shareWorth = withFees(shareWorth, amount); | ||
await external.publishShareWorth(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if the publishing fails you want receive
to throw?
I don't know of a way to recover from failed publishing so I tend to make the publish methods sync (and void
their .write()
)
Another reason to await is to get a signal that the write happened, but the only consumers are off-chain so I don't see why the contract should wait.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
my brain is leaky. I meant to head that way but forgot to or something
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good stuff!
publishShareWorth() { | ||
const { shareWorth } = this.state; | ||
const { recorder } = this.state.shareWorthRecorderKit; | ||
// Consumers of this .write() are off-chain / outside the VM. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
helpful comment!
this.state.proposalShapes.withdraw, | ||
); | ||
}, | ||
getPublicTopics() { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
*/ | ||
export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => { | ||
return zone.exoClassKit( | ||
'Fast Liquidity Pool', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
an exo kind is always per vat, right? no need to include Fast
.
This is also pretty close to being a general capability for managing a liquidity pool, especially if you move out the public interface.
825c566
to
26f451b
Compare
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)}`; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Consider
assertPoolBalance
- Can you talk more about what this invariant check is doing? How will it work when an advance is in flight?
There was a problem hiding this comment.
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 atomicRearrange
s 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.
- use on RatioShape
- using contractSupport/ratio upgrades zoe from devDependencies
- proposal shapes for deposit, withdraw - pool math with property testing
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
TODO:
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 aZCFMint
, not passed tostartInstance
as part of the issuerKeywordRecord.Testing Considerations
Consistent with plans to do multi-vat swingset tests later, we have:
Upgrade Considerations
The liquidity pool exo (and the public facet) are designed to survive upgrade; testing this is planned for later.