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

fix(fast-usdc): recover from bad lp proposal #10787

Merged
merged 1 commit into from
Jan 2, 2025
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
153 changes: 124 additions & 29 deletions packages/boot/test/fast-usdc/fast-usdc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,47 +200,49 @@ test.serial('writes account addresses to vstorage', async t => {
await documentStorageSchema(t, storage, doc);
});

test.serial('makes usdc advance', async t => {
const {
walletFactoryDriver: wd,
storage,
agoricNamesRemotes,
harness,
} = t.context;
const oracles = await Promise.all([
wd.provideSmartWallet('agoric19uscwxdac6cf6z7d5e26e0jm0lgwstc47cpll8'),
wd.provideSmartWallet('agoric1krunjcqfrf7la48zrvdfeeqtls5r00ep68mzkr'),
wd.provideSmartWallet('agoric1n4fcxsnkxe4gj6e24naec99hzmc4pjfdccy5nj'),
]);
await Promise.all(
oracles.map(wallet =>
wallet.sendOffer({
id: 'claim-oracle-invitation',
invitationSpec: {
source: 'purse',
instance: agoricNamesRemotes.instance.fastUsdc,
description: 'oracle operator invitation',
},
proposal: {},
}),
),
test.serial('LP deposits', async t => {
const { walletFactoryDriver: wd, agoricNamesRemotes } = t.context;
const lp = await wd.provideSmartWallet(
'agoric19uscwxdac6cf6z7d5e26e0jm0lgwstc47cpll8',
);

const lp = oracles[0]; // somewhat arbitrary

// @ts-expect-error it doesnt recognize usdc as a Brand type
// @ts-expect-error it doesnt recognize USDC as a Brand type
const usdc = agoricNamesRemotes.vbankAsset.USDC.brand as Brand<'nat'>;
// @ts-expect-error it doesnt recognize FastLP as a Brand type
const fastLP = agoricNamesRemotes.vbankAsset.FastLP.brand as Brand<'nat'>;

// Send a bad proposal first to make sure it's recoverable.
await lp.sendOffer({
id: 'deposit-lp-0',
invitationSpec: {
source: 'agoricContract',
instancePath: ['fastUsdc'],
callPipe: [['makeDepositInvitation', []]],
},
proposal: {
give: {
USDC: { brand: usdc, value: 98_000_000n },
},
want: {
BADPROPOSAL: { brand: fastLP, value: 567_000_000n },
},
},
});

await lp.sendOffer({
id: 'deposit-lp-1',
invitationSpec: {
source: 'agoricContract',
instancePath: ['fastUsdc'],
callPipe: [['makeDepositInvitation', []]],
},
proposal: {
give: {
USDC: { brand: usdc, value: 150_000_000n },
},
want: {
PoolShare: { brand: fastLP, value: 150_000_000n },
},
},
});
await eventLoopIteration();
Expand All @@ -252,15 +254,45 @@ test.serial('makes usdc advance', async t => {
obj.denom === 'ufastlp' &&
obj.recipient === lp.getAddress(),
);
t.log('LP vbank deposit', lpBankDeposit);
t.true(BigInt(lpBankDeposit.amount) > 1_000_000n, 'vbank GIVEs shares to LP');
t.log('LP vbank deposits', lpBankDeposit);
t.true(
BigInt(lpBankDeposit.amount) === 150_000_000n,
'vbank GIVEs shares to LP',
);

const { purses } = lp.getCurrentWalletRecord();
// XXX #10491 should not need to resort to string match on brand
t.falsy(
purses.find(p => `${p.brand}`.match(/FastLP/)),
'FastLP balance not in wallet record',
);
});

test.serial('makes usdc advance', async t => {
const {
walletFactoryDriver: wd,
storage,
agoricNamesRemotes,
harness,
} = t.context;
const oracles = await Promise.all([
wd.provideSmartWallet('agoric19uscwxdac6cf6z7d5e26e0jm0lgwstc47cpll8'),
wd.provideSmartWallet('agoric1krunjcqfrf7la48zrvdfeeqtls5r00ep68mzkr'),
wd.provideSmartWallet('agoric1n4fcxsnkxe4gj6e24naec99hzmc4pjfdccy5nj'),
]);
await Promise.all(
oracles.map(wallet =>
wallet.sendOffer({
id: 'claim-oracle-invitation',
invitationSpec: {
source: 'purse',
instance: agoricNamesRemotes.instance.fastUsdc,
description: 'oracle operator invitation',
},
proposal: {},
}),
),
);

const EUD = 'dydx1anything';
const lastNodeValue = storage.getValues('published.fastUsdc').at(-1);
Expand Down Expand Up @@ -361,6 +393,69 @@ test.serial('skips usdc advance when risks identified', async t => {
await documentStorageSchema(t, storage, doc);
});

test.serial('LP withdraws', async t => {
const { walletFactoryDriver: wd, agoricNamesRemotes } = t.context;
const lp = await wd.provideSmartWallet(
'agoric19uscwxdac6cf6z7d5e26e0jm0lgwstc47cpll8',
);

// @ts-expect-error it doesnt recognize USDC as a Brand type
const usdc = agoricNamesRemotes.vbankAsset.USDC.brand as Brand<'nat'>;
// @ts-expect-error it doesnt recognize FastLP as a Brand type
const fastLP = agoricNamesRemotes.vbankAsset.FastLP.brand as Brand<'nat'>;

// Send a bad proposal first to make sure it's recoverable.
await lp.sendOffer({
id: 'withdraw-lp-bad-shape',
invitationSpec: {
source: 'agoricContract',
instancePath: ['fastUsdc'],
callPipe: [['makeWithdrawInvitation', []]],
},
proposal: {
give: {
PoolShare: { brand: fastLP, value: 777_000n },
},
want: {
BADPROPOSALSHAPE: { brand: usdc, value: 777_000n },
},
},
});

await lp.sendOffer({
id: 'withdraw-lp-1',
invitationSpec: {
source: 'agoricContract',
instancePath: ['fastUsdc'],
callPipe: [['makeWithdrawInvitation', []]],
},
proposal: {
give: {
PoolShare: { brand: fastLP, value: 369_000n },
},
want: {
USDC: { brand: usdc, value: 369_000n },
},
},
});
await eventLoopIteration();

const { denom: usdcDenom } = agoricNamesRemotes.vbankAsset.USDC;
const { getOutboundMessages } = t.context.bridgeUtils;
const lpBankDeposits = getOutboundMessages(BridgeId.BANK).filter(
obj =>
obj.type === 'VBANK_GIVE' &&
obj.denom === usdcDenom &&
obj.recipient === lp.getAddress(),
);
t.log('LP vbank deposits', lpBankDeposits);
// Check index 2. Indexes 0 and 1 would be from the deposit offers in prior testcase.
t.true(
BigInt(lpBankDeposits[2].amount) >= 369_000n,
'vbank GIVEs USDC back to LP',
);
});

test.serial('restart contract', async t => {
const { EV } = t.context.runUtils;
await null;
Expand Down
16 changes: 15 additions & 1 deletion packages/fast-usdc/src/cli/lp-commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { AmountMath } from '@agoric/ertp';
import {
assertParsableNumber,
ceilDivideBy,
floorDivideBy,
multiplyBy,
parseRatio,
} from '@agoric/zoe/src/contractSupport/ratio.js';
Expand Down Expand Up @@ -81,18 +82,32 @@ export const addLPCommands = (
.action(async opts => {
swkP ||= loadSwk();
const swk = await swkP;

/** @type {Brand<'nat'>} */
// @ts-expect-error it doesnt recognize usdc as a Brand type
const usdc = swk.agoricNames.brand.USDC;
assert(usdc, 'USDC brand not in agoricNames');

/** @type {Brand<'nat'>} */
// @ts-expect-error it doesnt recognize FastLP as a Brand type
const poolShare = swk.agoricNames.brand.FastLP;
assert(poolShare, 'FastLP brand not in agoricNames');

const usdcAmount = parseUSDCAmount(opts.amount, usdc);

/** @type {import('../types.js').PoolMetrics} */
// @ts-expect-error it treats this as "unknown"
const metrics = await swk.readPublished('fastUsdc.poolMetrics');
const fastLPAmount = floorDivideBy(usdcAmount, metrics.shareWorth);

/** @type {USDCProposalShapes['deposit']} */
const proposal = {
give: {
USDC: usdcAmount,
},
want: {
PoolShare: fastLPAmount,
},
};

/** @type {OfferSpec} */
Expand Down Expand Up @@ -125,7 +140,6 @@ export const addLPCommands = (
.requiredOption('--amount <number>', 'USDC amount', parseDecimal)
.option('--offerId <string>', 'Offer id', String, `lpWithdraw-${now()}`)
.action(async opts => {
swkP ||= loadSwk();
swkP ||= loadSwk();
const swk = await swkP;

Expand Down
2 changes: 1 addition & 1 deletion packages/fast-usdc/src/pool-share-math.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const makeParity = (numerator, denominatorBrand) => {
* @typedef {{
* deposit: {
* give: { USDC: Amount<'nat'> },
* want?: { PoolShare: Amount<'nat'> }
* want: { PoolShare: Amount<'nat'> }
* },
* withdraw: {
* give: { PoolShare: Amount<'nat'> }
Expand Down
8 changes: 4 additions & 4 deletions packages/fast-usdc/src/type-guards.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ export const makeNatAmountShape = (brand, min) =>
/** @param {Record<'PoolShares' | 'USDC', Brand<'nat'>>} brands */
export const makeProposalShapes = ({ PoolShares, USDC }) => {
/** @type {TypedPattern<USDCProposalShapes['deposit']>} */
const deposit = M.splitRecord(
{ give: { USDC: makeNatAmountShape(USDC, 1n) } },
{ want: M.splitRecord({}, { PoolShare: makeNatAmountShape(PoolShares) }) },
);
const deposit = M.splitRecord({
give: { USDC: makeNatAmountShape(USDC, 1n) },
want: { PoolShare: makeNatAmountShape(PoolShares) },
});
/** @type {TypedPattern<USDCProposalShapes['withdraw']>} */
const withdraw = M.splitRecord({
give: { PoolShare: makeNatAmountShape(PoolShares, 1n) },
Expand Down
5 changes: 4 additions & 1 deletion packages/fast-usdc/test/cli/lp-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const test = anyTest as TestFn<Awaited<ReturnType<typeof makeTestContext>>>;
test.beforeEach(async t => (t.context = await makeTestContext()));

test('fast-usdc deposit command', async t => {
const { program, marshaller, out, err, USDC } = t.context;
const { program, marshaller, out, err, USDC, FastLP } = t.context;
const amount = 100.05;
const argv = [...`node fast-usdc deposit`.split(' '), ...flags({ amount })];
t.log(...argv);
Expand All @@ -78,6 +78,9 @@ test('fast-usdc deposit command', async t => {
give: {
USDC: { brand: USDC, value: 100_050_000n },
},
want: {
PoolShare: { brand: FastLP, value: 90_954_545n },
},
},
},
});
Expand Down
36 changes: 30 additions & 6 deletions packages/fast-usdc/test/pool-share-math.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,12 @@ test('initial withdrawal fails', t => {
test('withdrawal after deposit OK', t => {
const { PoolShares, USDC } = brands;
const state0 = makeParity(make(USDC, 1n), PoolShares);
const emptyShares = makeEmpty(PoolShares);

const pDep = { give: { USDC: make(USDC, 100n) } };
const pDep = {
give: { USDC: make(USDC, 100n) },
want: { PoolShare: emptyShares },
};
const { shareWorth: state1 } = depositCalc(state0, pDep);

const proposal = harden({
Expand All @@ -81,8 +85,12 @@ test('withdrawal after deposit OK', t => {

test('deposit offer underestimates value of share', t => {
const { PoolShares, USDC } = brands;
const emptyShares = makeEmpty(PoolShares);

const pDep = { give: { USDC: make(USDC, 100n) } };
const pDep = {
give: { USDC: make(USDC, 100n) },
want: { PoolShare: emptyShares },
};
const { shareWorth: state1 } = depositCalc(parity, pDep);
const state2 = withFees(state1, make(USDC, 20n));

Expand Down Expand Up @@ -119,9 +127,13 @@ test('deposit offer overestimates value of share', t => {

test('withdrawal offer underestimates value of share', t => {
const { PoolShares, USDC } = brands;
const emptyShares = makeEmpty(PoolShares);
const state0 = makeParity(make(USDC, 1n), PoolShares);

const proposal1 = harden({ give: { USDC: make(USDC, 100n) } });
const proposal1 = harden({
give: { USDC: make(USDC, 100n) },
want: { PoolShare: emptyShares },
});
const { shareWorth: state1 } = depositCalc(state0, proposal1);

const proposal = harden({
Expand All @@ -143,9 +155,13 @@ test('withdrawal offer underestimates value of share', t => {

test('withdrawal offer overestimates value of share', t => {
const { PoolShares, USDC } = brands;
const emptyShares = makeEmpty(PoolShares);
const state0 = makeParity(make(USDC, 1n), PoolShares);

const d100 = { give: { USDC: make(USDC, 100n) } };
const d100 = {
give: { USDC: make(USDC, 100n) },
want: { PoolShare: emptyShares },
};
const { shareWorth: state1 } = depositCalc(state0, d100);

const proposal = harden({
Expand Down Expand Up @@ -196,7 +212,12 @@ testProp(
'deposit properties',
[arbShareWorth, arbUSDC],
(t, shareWorth, In) => {
const actual = depositCalc(shareWorth, { give: { USDC: In } });
const { PoolShares } = brands;
const emptyShares = makeEmpty(PoolShares);
const actual = depositCalc(shareWorth, {
give: { USDC: In },
want: { PoolShare: emptyShares },
});
const {
payouts: { PoolShare },
shareWorth: post,
Expand Down Expand Up @@ -234,7 +255,10 @@ testProp(

for (const { party, action } of actions) {
if ('In' in action) {
const d = depositCalc(shareWorth, { give: { USDC: action.In } });
const d = depositCalc(shareWorth, {
give: { USDC: action.In },
want: { PoolShare: emptyShares },
});
myShares[party] = add(
myShares[party] || emptyShares,
d.payouts.PoolShare,
Expand Down
Loading