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): cli for lp deposit and withdraw #10636

Merged
merged 1 commit into from
Dec 10, 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
16 changes: 12 additions & 4 deletions packages/fast-usdc/src/cli/bridge-action.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import { boardSlottingMarshaller } from '@agoric/client-utils';
* @import {BridgeAction} from '@agoric/smart-wallet/src/smartWallet.js';
*/

const marshaller = boardSlottingMarshaller();
const defaultMarshaller = boardSlottingMarshaller();

/** @typedef {ReturnType<boardSlottingMarshaller>} BoardSlottingMarshaller */

/**
* @param {BridgeAction} bridgeAction
* @param {Pick<import('stream').Writable,'write'>} stdout
* @param {BoardSlottingMarshaller} marshaller
*/
const outputAction = (bridgeAction, stdout) => {
const outputAction = (bridgeAction, stdout, marshaller) => {
const capData = marshaller.toCapData(harden(bridgeAction));
stdout.write(JSON.stringify(capData));
stdout.write('\n');
Expand All @@ -25,8 +28,13 @@ export const sendHint =
* stdout: Pick<import('stream').Writable,'write'>,
* stderr: Pick<import('stream').Writable,'write'>,
* }} io
* @param {BoardSlottingMarshaller | undefined} marshaller
*/
export const outputActionAndHint = (bridgeAction, { stdout, stderr }) => {
outputAction(bridgeAction, stdout);
export const outputActionAndHint = (
bridgeAction,
{ stdout, stderr },
marshaller = defaultMarshaller,
) => {
outputAction(bridgeAction, stdout, marshaller);
dckc marked this conversation as resolved.
Show resolved Hide resolved
stderr.write(sendHint);
};
54 changes: 3 additions & 51 deletions packages/fast-usdc/src/cli/cli.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
/* eslint-env node */
/* global globalThis */
import { assertParsableNumber } from '@agoric/zoe/src/contractSupport/ratio.js';
import {
Command,
InvalidArgumentError,
InvalidOptionArgumentError,
} from 'commander';
import { Command } from 'commander';
import { existsSync, mkdirSync, readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
Expand All @@ -19,6 +14,7 @@ import { addOperatorCommands } from './operator-commands.js';
import * as configLib from './config.js';
import transferLib from './transfer.js';
import { makeFile } from '../util/file.js';
import { addLPCommands } from './lp-commands.js';

const packageJson = JSON.parse(
readFileSync(
Expand Down Expand Up @@ -83,51 +79,7 @@ export const initProgram = (
env,
now,
});

/** @param {string} value */
const parseDecimal = value => {
try {
assertParsableNumber(value);
} catch {
throw new InvalidArgumentError('Not a decimal number.');
}
return value;
};

/**
* @param {string} str
* @returns {'auto' | number}
*/
const parseFee = str => {
if (str === 'auto') return 'auto';
const num = parseFloat(str);
if (Number.isNaN(num)) {
throw new InvalidOptionArgumentError('Fee must be a number.');
}
return num;
};

program
.command('deposit')
.description('Offer assets to the liquidity pool')
.argument('<give>', 'USDC to give', parseDecimal)
.option('--id [offer-id]', 'Offer ID')
.option('--fee [fee]', 'Cosmos fee', parseFee)
.action(() => {
console.error('TODO actually send deposit');
// TODO: Implement deposit logic
});

program
.command('withdraw')
.description('Withdraw assets from the liquidity pool')
.argument('<want>', 'USDC to withdraw', parseDecimal)
.option('--id [offer-id]', 'Offer ID')
.option('--fee [fee]', 'Cosmos fee', parseFee)
.action(() => {
console.error('TODO actually send withdrawal');
// TODO: Implement withdraw logic
});
addLPCommands(program, { fetch, stdout, stderr, env, now });

program
.command('transfer')
Expand Down
171 changes: 171 additions & 0 deletions packages/fast-usdc/src/cli/lp-commands.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* @import {Command} from 'commander';
* @import {OfferSpec} from '@agoric/smart-wallet/src/offers.js';
* @import {ExecuteOfferAction} from '@agoric/smart-wallet/src/smartWallet.js';
* @import {USDCProposalShapes} from '../pool-share-math.js';
*/

import { fetchEnvNetworkConfig, makeVstorageKit } from '@agoric/client-utils';
import { InvalidArgumentError } from 'commander';
import {
assertParsableNumber,
ceilDivideBy,
multiplyBy,
parseRatio,
} from '@agoric/zoe/src/contractSupport/ratio.js';
import { AmountMath } from '@agoric/ertp';
import { outputActionAndHint } from './bridge-action.js';

/** @param {string} arg */
const parseDecimal = arg => {
try {
assertParsableNumber(arg);
const n = Number(arg);
return n;
} catch {
throw new InvalidArgumentError('Not a number');
}
};

/**
* @param {string} amountString
* @param {Brand} usdc
*/
const parseUSDCAmount = (amountString, usdc) => {
const USDC_DECIMALS = 6;
const unit = AmountMath.make(usdc, 10n ** BigInt(USDC_DECIMALS));
return multiplyBy(unit, parseRatio(amountString, usdc));
};

/**
* @param {Command} program
* @param {{
* fetch?: Window['fetch'];
* vstorageKit?: Awaited<ReturnType<typeof makeVstorageKit>>;
dckc marked this conversation as resolved.
Show resolved Hide resolved
* stdout: typeof process.stdout;
* stderr: typeof process.stderr;
* env: typeof process.env;
* now: typeof Date.now;
* }} io
*/
export const addLPCommands = (
program,
{ fetch, vstorageKit, stderr, stdout, env, now },
) => {
const loadVsk = async () => {
if (vstorageKit) {
return vstorageKit;
}
assert(fetch);
const networkConfig = await fetchEnvNetworkConfig({ env, fetch });
return makeVstorageKit({ fetch }, networkConfig);
};
/** @type {undefined | ReturnType<typeof loadVsk>} */
let vskP;

program
.command('deposit')
.description('Deposit USDC into pool')
.addHelpText(
'after',
'\nPipe the STDOUT to a file such as deposit.json, then use the Agoric CLI to broadcast it:\n agoric wallet send --offer deposit.json --from gov1 --keyring-backend="test"',
)
.requiredOption('--amount <number>', 'USDC amount', parseDecimal)
.option('--offerId <string>', 'Offer id', String, `lpDeposit-${now()}`)
.action(async opts => {
vskP ||= loadVsk();
const vsk = await vskP;
/** @type {Brand<'nat'>} */
// @ts-expect-error it doesnt recognize usdc as a Brand type
Copy link
Member

Choose a reason for hiding this comment

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

boring. Here's hoping we clean that up: #10491

const usdc = vsk.agoricNames.brand.USDC;
assert(usdc, 'USDC brand not in agoricNames');

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

/** @type {USDCProposalShapes['deposit']} */
const proposal = {
give: {
USDC: usdcAmount,
Comment on lines +86 to +88
Copy link
Member

Choose a reason for hiding this comment

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

For the production UI, the proposal should include want too, based on shareWorth like in withdraw.

For test use, we may want to be able to specify it manually.

I wonder if --amount should be --give.

But we can cross those bridges when we get to them.

},
};

/** @type {OfferSpec} */
const offer = {
id: opts.offerId,
invitationSpec: {
source: 'agoricContract',
instancePath: ['fastUsdc'],
callPipe: [['makeDepositInvitation', []]],
},
proposal,
};

/** @type {ExecuteOfferAction} */
const bridgeAction = {
method: 'executeOffer',
offer,
};

outputActionAndHint(bridgeAction, { stderr, stdout }, vsk.marshaller);
});

program
.command('withdraw')
.description("Withdraw USDC from the LP's pool share")
.addHelpText(
'after',
'\nPipe the STDOUT to a file such as withdraw.json, then use the Agoric CLI to broadcast it:\n agoric wallet send --offer withdraw.json --from gov1 --keyring-backend="test"',
)
.requiredOption('--amount <number>', 'USDC amount', parseDecimal)
.option('--offerId <string>', 'Offer id', String, `lpWithdraw-${now()}`)
.action(async opts => {
vskP ||= loadVsk();
const vsk = await vskP;

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

/** @type {Brand<'nat'>} */
// @ts-expect-error it doesnt recognize usdc as a Brand type
const usdc = vsk.agoricNames.brand.USDC;
assert(usdc, 'USDC 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 vsk.readPublished('fastUsdc.poolMetrics');
const fastLPAmount = ceilDivideBy(usdcAmount, metrics.shareWorth);
Copy link
Member

Choose a reason for hiding this comment

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

There's a TOCTTOU issuer here: the shareWorth could change between the time we read it here and the time our offer gets to the contract.

As an enhancement, we could add some sort of acceptable slippage option, in due course.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right


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

/** @type {OfferSpec} */
const offer = {
id: opts.offerId,
invitationSpec: {
source: 'agoricContract',
instancePath: ['fastUsdc'],
callPipe: [['makeWithdrawInvitation', []]],
},
proposal,
};

outputActionAndHint(
{ method: 'executeOffer', offer },
{ stderr, stdout },
vsk.marshaller,
);
});

return program;
};
4 changes: 4 additions & 0 deletions packages/fast-usdc/src/cli/operator-commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ export const addOperatorCommands = (
operator
.command('attest')
.description('Attest to an observed Fast USDC transfer')
.addHelpText(
'after',
'\nPipe the STDOUT to a file such as attest.json, then use the Agoric CLI to broadcast it:\n agoric wallet send --offer attest.json --from gov1 --keyring-backend="test"',
)
.requiredOption('--previousOfferId <string>', 'Offer id', String)
.requiredOption('--forwardingChannel <string>', 'Channel id', String)
.requiredOption('--recipientAddress <string>', 'bech32 address', String)
Expand Down
24 changes: 2 additions & 22 deletions packages/fast-usdc/test/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,28 +81,13 @@ test('shows help for config show command', async t => {
t.snapshot(output);
});

test('shows help for deposit command', async t => {
const output = await runCli(['deposit', '-h']);
t.snapshot(output);
});

test('shows help for withdraw command', async t => {
const output = await runCli(['withdraw', '-h']);
t.snapshot(output);
});

test('shows error when deposit command is run without options', async t => {
const output = await runCli(['deposit']);
t.snapshot(output);
});

test('shows error when deposit command is run with invalid amount', async t => {
const output = await runCli(['deposit', 'not-a-number']);
t.snapshot(output);
});

test('shows error when deposit command is run with invalid fee', async t => {
const output = await runCli(['deposit', '50', '--fee', 'not-a-number']);
const output = await runCli(['deposit', '--amount', 'not-a-number']);
t.snapshot(output);
});

Expand All @@ -112,12 +97,7 @@ test('shows error when withdraw command is run without options', async t => {
});

test('shows error when withdraw command is run with invalid amount', async t => {
const output = await runCli(['withdraw', 'not-a-number']);
t.snapshot(output);
});

test('shows error when withdraw command is run with invalid fee', async t => {
const output = await runCli(['withdraw', '50', '--fee', 'not-a-number']);
const output = await runCli(['withdraw', '--amount', 'not-a-number']);
t.snapshot(output);
});

Expand Down
Loading
Loading