diff --git a/packages/agoric-cli/package.json b/packages/agoric-cli/package.json index 6f57b7fce4a..eafe9acba78 100644 --- a/packages/agoric-cli/package.json +++ b/packages/agoric-cli/package.json @@ -37,6 +37,7 @@ "@agoric/inter-protocol": "^0.13.1", "@agoric/internal": "^0.2.1", "@agoric/smart-wallet": "^0.4.2", + "@agoric/store": "^0.8.3", "@agoric/swingset-vat": "^0.30.2", "@agoric/vats": "^0.13.0", "@agoric/zoe": "^0.25.3", diff --git a/packages/agoric-cli/src/bin-agops.js b/packages/agoric-cli/src/bin-agops.js index 31b5ff6bea3..21b96dc83a0 100755 --- a/packages/agoric-cli/src/bin-agops.js +++ b/packages/agoric-cli/src/bin-agops.js @@ -1,20 +1,24 @@ #!/usr/bin/env node +// @ts-check /* eslint-disable @jessie.js/no-nested-await */ -/* global process */ +/* global fetch */ import '@agoric/casting/node-fetch-shim.js'; import '@endo/init'; import '@endo/init/pre.js'; -import anylogger from 'anylogger'; -import { Command } from 'commander'; +import { execFileSync } from 'child_process'; import path from 'path'; +import process from 'process'; +import anylogger from 'anylogger'; +import { Command, CommanderError, createCommand } from 'commander'; import { makeOracleCommand } from './commands/oracle.js'; import { makeEconomicCommiteeCommand } from './commands/ec.js'; import { makePsmCommand } from './commands/psm.js'; import { makeReserveCommand } from './commands/reserve.js'; import { makeVaultsCommand } from './commands/vaults.js'; import { makePerfCommand } from './commands/perf.js'; +import { makeInterCommand } from './commands/inter.js'; const logger = anylogger('agops'); const progname = path.basename(process.argv[1]); @@ -29,4 +33,27 @@ program.addCommand(await makePsmCommand(logger)); program.addCommand(await makeReserveCommand(logger)); program.addCommand(await makeVaultsCommand(logger)); -await program.parseAsync(process.argv); +program.addCommand( + await makeInterCommand( + { + env: { ...process.env }, + stdout: process.stdout, + stderr: process.stderr, + createCommand, + execFileSync, + now: () => Date.now(), + }, + { fetch }, + ), +); + +try { + await program.parseAsync(process.argv); +} catch (err) { + if (err instanceof CommanderError) { + console.error(err.message); + } else { + console.error(err); // CRASH! show stack trace + } + process.exit(1); +} diff --git a/packages/agoric-cli/src/commands/inter.js b/packages/agoric-cli/src/commands/inter.js new file mode 100644 index 00000000000..6a50debc61a --- /dev/null +++ b/packages/agoric-cli/src/commands/inter.js @@ -0,0 +1,382 @@ +// @ts-check +import { CommanderError, InvalidArgumentError } from 'commander'; +// TODO: should get M from endo https://github.com/Agoric/agoric-sdk/issues/7090 +import { M, matches } from '@agoric/store'; +import { objectMap } from '@agoric/internal'; +import { Offers } from '@agoric/inter-protocol/src/clientSupport.js'; +import { makeBidSpecShape } from '@agoric/inter-protocol/src/auction/auctionBook.js'; +import { makeWalletStateCoalescer } from '@agoric/smart-wallet/src/utils.js'; +import { + boardSlottingMarshaller, + getNetworkConfig, + makeRpcUtils, + storageHelper, +} from '../lib/rpc.js'; +import { outputExecuteOfferAction } from '../lib/wallet.js'; +import { normalizeAddressWithOptions } from '../lib/chain.js'; +import { + asBoardRemote, + bigintReplacer, + makeAmountFormatter, +} from '../lib/format.js'; + +const { values } = Object; + +/** @typedef {import('@agoric/vats/tools/board-utils.js').VBankAssetDetail } AssetDescriptor */ + +/** + * Format amounts, prices etc. based on brand board Ids, displayInfo + * + * @param {AssetDescriptor[]} assets + */ +const makeFormatters = assets => { + const br = asBoardRemote; + const fmtAmtTuple = makeAmountFormatter(assets); + /** @param {Amount} amt */ + const amount = amt => (([l, m]) => `${m}${l}`)(fmtAmtTuple(br(amt))); + /** @param {Record | undefined} r */ + const record = r => (r ? objectMap(r, amount) : undefined); + /** @param {Ratio} r */ + const price = r => { + const [nl, nm] = fmtAmtTuple(br(r.numerator)); + const [dl, dm] = fmtAmtTuple(br(r.denominator)); + return `${Number(nm) / Number(dm)} ${nl}/${dl}`; + }; + const discount = r => + 100 - (Number(r.numerator.value) / Number(r.denominator.value)) * 100; + return { amount, record, price, discount }; +}; + +const fmtMetrics = (metrics, quote, assets) => { + const fmt = makeFormatters(assets); + const { liquidatingCollateral, liquidatingDebt } = metrics; + + const { + quoteAmount: { + value: [{ amountIn, amountOut }], + }, + } = quote; + const price = fmt.price({ numerator: amountOut, denominator: amountIn }); + + const amounts = objectMap( + { liquidatingCollateral, liquidatingDebt }, + fmt.amount, + ); + return { ...amounts, price }; +}; + +/** + * Format amounts etc. in a bid OfferStatus + * + * @param {import('@agoric/smart-wallet/src/offers.js').OfferStatus & + * { offerArgs: import('@agoric/inter-protocol/src/auction/auctionBook.js').BidSpec}} bid + * @param {import('agoric/src/lib/format.js').AssetDescriptor[]} assets + */ +export const fmtBid = (bid, assets) => { + const fmt = makeFormatters(assets); + + const { offerArgs } = bid; + /** @type {{ price: string } | { discount: number }} */ + const spec = + 'offerPrice' in offerArgs + ? { price: fmt.price(offerArgs.offerPrice) } + : { discount: fmt.discount(offerArgs.offerBidScaling) }; + + const { + id, + error, + proposal: { give }, + offerArgs: { want }, + payouts, + } = bid; + const props = { + ...(give ? { give: fmt.record(give) } : {}), + ...(want ? { want: fmt.amount(want) } : {}), + ...(payouts ? { payouts: fmt.record(payouts) } : {}), + ...(error ? { error } : {}), + }; + return harden({ id, ...spec, ...props }); +}; + +/** + * @param {{ + * env: Partial>, + * stdout: Pick, + * stderr: Pick, + * now: () => number, + * createCommand: // Note: includes access to process.stdout, .stderr, .exit + * typeof import('commander').createCommand, + * execFileSync: typeof import('child_process').execFileSync + * }} process + * @param {{ fetch: typeof window.fetch }} net + */ +export const makeInterCommand = async ( + { env, stdout, stderr, now, execFileSync, createCommand }, + { fetch }, +) => { + const interCmd = createCommand('inter') + .description('Inter Protocol tool') + .option('--home [dir]', 'agd CosmosSDK application home directory') + .option( + '--keyring-backend [os|file|test]', + 'keyring\'s backend (os|file|test) (default "os")', + ); + + const rpcTools = async () => { + const networkConfig = await getNetworkConfig(env); + const { agoricNames, fromBoard, vstorage } = await makeRpcUtils( + { fetch }, + networkConfig, + ).catch(err => { + throw new CommanderError( + 1, + 'RPC_FAIL', + `RPC failure (${env.AGORIC_NET || 'local'}): ${err.message}`, + ); + }); + const unserializer = boardSlottingMarshaller(fromBoard.convertSlotToVal); + const unBoard = txt => storageHelper.unserializeTxt(txt, fromBoard).at(-1); + return { + networkConfig, + agoricNames, + fromBoard, + unserializer, + vstorage, + unBoard, + }; + }; + + const liquidationCmd = interCmd + .command('liquidation') + .description('liquidation commands'); + liquidationCmd + .command('status') + .description( + `show amount liquidating, oracle price + +For example: + +{ + "liquidatingCollateral": "10IbcATOM", + "liquidatingDebt": "120IST", + "price": "12.00 IST/IbcATOM" +} +`, + ) + .option('--manager [number]', 'Vault Manager', Number, 0) + .action(async opts => { + const { agoricNames, vstorage, unBoard } = await rpcTools(); + + const [metrics, quote] = await Promise.all([ + vstorage + .readLatest(`published.vaultFactory.manager${opts.manager}.metrics`) + .then(unBoard), + vstorage + .readLatest(`published.vaultFactory.manager${opts.manager}.quotes`) + .then(unBoard), + ]); + const info = fmtMetrics(metrics, quote, values(agoricNames.vbankAsset)); + stdout.write(JSON.stringify(info, bigintReplacer, 2)); + stdout.write('\n'); + }); + + const bidCmd = interCmd + .command('bid') + .description('auction bidding commands'); + + bidCmd + .command('by-price') + .description('Print an offer to bid collateral by price.') + .requiredOption('--price [number]', 'bid price', Number) + .requiredOption('--giveCurrency [number]', 'Currency to give', Number) + .requiredOption( + '--wantCollateral [number]', + 'Collateral expected for the currency', + Number, + ) + .option('--collateralBrand [string]', 'Collateral brand key', 'IbcATOM') + .option('--offerId [number]', 'Offer id', String, `bid-${now()}`) + .action( + /** + * @param {{ + * price: number, + * giveCurrency: number, wantCollateral: number, + * collateralBrand: string, + * offerId: string, + * }} opts + */ + async ({ collateralBrand, ...opts }) => { + const { agoricNames } = await rpcTools(); + const offer = Offers.auction.Bid(agoricNames.brand, { + collateralBrandKey: collateralBrand, + ...opts, + }); + outputExecuteOfferAction(offer, stdout); + stderr.write( + 'Now use `agoric wallet send ...` to sign and broadcast the offer.\n', + ); + }, + ); + + const parsePercent = v => { + const p = Number(v); + if (!(p >= -100 && p <= 100)) { + throw new InvalidArgumentError('must be between -100 and 100'); + } + return p / 100; + }; + + bidCmd + .command('by-discount') + .description( + `Print an offer to bid on collateral based on discount from oracle price.`, + ) + .requiredOption( + '--discount [percent]', + 'bid discount (0 to 100) or markup (0 to -100) %', + parsePercent, + ) + .requiredOption('--giveCurrency [number]', 'Currency to give', Number) + .requiredOption('--wantCollateral [number]', 'bid price', Number) + .option('--collateralBrand [string]', 'Collateral brand key', 'IbcATOM') + .option('--offerId [number]', 'Offer id', String, `bid-${now()}`) + .action( + /** + * @param {{ + * discount: number, + * giveCurrency: number, wantCollateral: number, + * collateralBrand: string, + * offerId: string, + * }} opts + */ + async ({ collateralBrand, ...opts }) => { + const { agoricNames } = await rpcTools(); + const offer = Offers.auction.Bid(agoricNames.brand, { + collateralBrandKey: collateralBrand, + ...opts, + }); + outputExecuteOfferAction(offer, stdout); + stderr.write( + 'Now use `agoric wallet send ...` to sign and broadcast the offer.\n', + ); + }, + ); + + const normalizeAddress = literalOrName => + normalizeAddressWithOptions(literalOrName, interCmd.opts(), { + execFileSync, + }); + + bidCmd + .command('list') + .description( + `Show status of bid offers. + +For example: + +$ inter bid list --from my-acct +{"id":"bid-1679677228803","price":"9 IST/IbcATOM","give":{"Currency":"50IST"},"want":"5IbcATOM"} +{"id":"bid-1679677312341","discount":10,"give":{"Currency":"200IST"},"want":"1IbcATOM"} +`, + ) + .requiredOption( + '--from
', + 'wallet address literal or name', + normalizeAddress, + ) + .action(async opts => { + const { agoricNames, vstorage, fromBoard } = await rpcTools(); + const m = boardSlottingMarshaller(fromBoard.convertSlotToVal); + + const history = await vstorage.readFully(`published.wallet.${opts.from}`); + + /** @type {{ Invitation: Brand<'set'> }} */ + // @ts-expect-error XXX how to narrow AssetKind to set? + const { Invitation } = agoricNames.brand; + const coalescer = makeWalletStateCoalescer(Invitation); + // update with oldest first + for (const txt of history.reverse()) { + const { body, slots } = JSON.parse(txt); + const record = m.unserialize({ body, slots }); + coalescer.update(record); + } + const coalesced = coalescer.state; + const bidInvitationShape = harden({ + source: 'agoricContract', + instancePath: ['auctioneer'], + callPipe: [['makeBidInvitation', M.any()]], + }); + + /** + * @param {import('@agoric/smart-wallet/src/offers.js').OfferStatus} offerStatus + * @param {typeof console.warn} warn + */ + const coerceBid = (offerStatus, warn) => { + const { offerArgs } = offerStatus; + /** @type {unknown} */ + const collateralBrand = /** @type {any} */ (offerArgs)?.want?.brand; + if (!collateralBrand) { + warn('mal-formed bid offerArgs', offerArgs); + return null; + } + const bidSpecShape = makeBidSpecShape( + // @ts-expect-error XXX AssetKind narrowing? + agoricNames.brand.IST, + collateralBrand, + ); + if (!matches(offerStatus.offerArgs, bidSpecShape)) { + warn('mal-formed bid offerArgs', offerArgs); + return null; + } + + /** + * @type {import('@agoric/smart-wallet/src/offers.js').OfferStatus & + * { offerArgs: import('@agoric/inter-protocol/src/auction/auctionBook.js').BidSpec}} + */ + // @ts-expect-error dynamic cast + const bid = offerStatus; + return bid; + }; + + for (const offerStatus of coalesced.offerStatuses.values()) { + harden(offerStatus); // coalesceWalletState should do this + // console.debug(offerStatus.invitationSpec); + if (!matches(offerStatus.invitationSpec, bidInvitationShape)) continue; + + const bid = coerceBid(offerStatus, console.warn); + if (!bid) continue; + + const info = fmtBid(bid, values(agoricNames.vbankAsset)); + stdout.write(JSON.stringify(info)); + stdout.write('\n'); + } + }); + + const reserveCmd = interCmd + .command('reserve') + .description('reserve commands'); + reserveCmd + .command('add') + .description('add collateral to the reserve') + .requiredOption('--giveCollateral [number]', 'Collateral to give', Number) + .option('--collateralBrand [string]', 'Collateral brand key', 'IbcATOM') + .option('--offerId [number]', 'Offer id', String, `bid-${now()}`) + .action( + /** + * @param {{ + * giveCollateral: number, + * collateralBrand: string, + * offerId: string, + * }} opts + */ + async ({ collateralBrand, ...opts }) => { + const { agoricNames } = await rpcTools(); + const offer = Offers.reserve.AddCollateral(agoricNames.brand, { + collateralBrandKey: collateralBrand, + ...opts, + }); + outputExecuteOfferAction(offer, stdout); + }, + ); + return interCmd; +}; diff --git a/packages/agoric-cli/src/lib/chain.js b/packages/agoric-cli/src/lib/chain.js index d0809246871..0647192e101 100644 --- a/packages/agoric-cli/src/lib/chain.js +++ b/packages/agoric-cli/src/lib/chain.js @@ -8,6 +8,7 @@ const agdBinary = 'agd'; export const normalizeAddressWithOptions = ( literalOrName, { keyringBackend = undefined } = {}, + io = { execFileSync }, ) => { try { return normalizeBech32(literalOrName); @@ -16,7 +17,7 @@ export const normalizeAddressWithOptions = ( const backendOpt = keyringBackend ? [`--keyring-backend=${keyringBackend}`] : []; - const buff = execFileSync(agdBinary, [ + const buff = io.execFileSync(agdBinary, [ `keys`, ...backendOpt, `show`, diff --git a/packages/agoric-cli/src/lib/format.js b/packages/agoric-cli/src/lib/format.js index f2b8c99703d..e6c77a301a5 100644 --- a/packages/agoric-cli/src/lib/format.js +++ b/packages/agoric-cli/src/lib/format.js @@ -6,6 +6,8 @@ import { makeAgoricNames } from './rpc.js'; // ambient types import '@agoric/ertp/src/types-ambient.js'; +/** @typedef {import('@agoric/vats/tools/board-utils.js').BoardRemote} BoardRemote */ + /** * Like @endo/nat but coerces * @@ -20,6 +22,14 @@ export const Natural = str => { return b; }; +/** + * JSON.stringify replacer to handle bigint + * + * @param {unknown} k + * @param {unknown} v + */ +export const bigintReplacer = (k, v) => (typeof v === 'bigint' ? `${v}` : v); + /** @type {import('@agoric/vats/tools/board-utils.js').VBankAssetDetail} */ // eslint-disable-next-line no-unused-vars const exampleAsset = { @@ -30,16 +40,17 @@ const exampleAsset = { issuer: makeBoardRemote({ boardId: null, iface: undefined }), petname: 'Agoric staking token', }; -/** @typedef {import('@agoric/vats/tools/board-utils.js').VBankAssetDetail & { brand: import('@agoric/vats/tools/board-utils.js').BoardRemote }} AssetDescriptor */ +/** @typedef {import('@agoric/vats/tools/board-utils.js').VBankAssetDetail } AssetDescriptor */ -/** @param {AssetDescriptor[]} assets */ +/** + * @param {AssetDescriptor[]} assets + * @returns {(a: Amount & { brand: BoardRemote }) => [string, number | any[]]} + */ export const makeAmountFormatter = assets => amt => { - const { - brand: { boardId }, - value, - } = amt; + const { brand, value } = amt; + const boardId = brand.getBoardId(); const asset = assets.find(a => a.brand.getBoardId() === boardId); - if (!asset) return [NaN, boardId]; + if (!asset) return [boardId, Number(value)]; // don't crash const { displayInfo: { assetKind, decimalPlaces = 0 }, issuerName, @@ -48,12 +59,13 @@ export const makeAmountFormatter = assets => amt => { case 'nat': return [issuerName, Number(value) / 10 ** decimalPlaces]; case 'set': + assert(Array.isArray(value)); if (value[0]?.handle?.iface?.includes('InvitationHandle')) { return [issuerName, value.map(v => v.description)]; } return [issuerName, value]; default: - return [issuerName, ['?']]; + return [issuerName, [NaN]]; } }; @@ -63,6 +75,16 @@ export const asPercent = ratio => { return (100 * Number(numerator.value)) / Number(denominator.value); }; +/** + * @param {Amount} x + * @returns {Amount & { brand: BoardRemote }} + */ +export const asBoardRemote = x => { + assert('getBoardId' in x.brand); + // @ts-expect-error dynamic check + return x; +}; + /** * Summarize the balances array as user-facing informative tuples @@ -71,7 +93,7 @@ export const asPercent = ratio => { */ export const purseBalanceTuples = (purses, assets) => { const fmt = makeAmountFormatter(assets); - return purses.map(b => fmt(b.balance)); + return purses.map(b => fmt(asBoardRemote(b.balance))); }; /** diff --git a/packages/agoric-cli/src/lib/rpc.js b/packages/agoric-cli/src/lib/rpc.js index 935b8964342..d7979ffddd1 100644 --- a/packages/agoric-cli/src/lib/rpc.js +++ b/packages/agoric-cli/src/lib/rpc.js @@ -31,29 +31,52 @@ const fromAgoricNet = str => { return fetch(networkConfigUrl(netName)).then(res => res.json()); }; +/** + * @param {typeof process.env} env + * @returns {Promise} + */ +export const getNetworkConfig = async env => { + if (!('AGORIC_NET' in env) || env.AGORIC_NET === 'local') { + return { rpcAddrs: ['http://0.0.0.0:26657'], chainName: 'agoriclocal' }; + } + + return fromAgoricNet(NonNullish(env.AGORIC_NET)).catch(err => { + throw Error( + `cannot get network config (${env.AGORIC_NET || 'local'}): ${ + err.message + }`, + ); + }); +}; + /** @type {MinimalNetworkConfig} */ -export const networkConfig = - 'AGORIC_NET' in process.env && process.env.AGORIC_NET !== 'local' - ? await fromAgoricNet(NonNullish(process.env.AGORIC_NET)) - : { rpcAddrs: ['http://0.0.0.0:26657'], chainName: 'agoriclocal' }; +export const networkConfig = await getNetworkConfig(process.env); // console.warn('networkConfig', networkConfig); /** * * @param {object} powers * @param {typeof window.fetch} powers.fetch + * @param {MinimalNetworkConfig} config */ -export const makeVStorage = powers => { +export const makeVStorage = (powers, config = networkConfig) => { + /** @param {string} path */ const getJSON = path => { - const url = networkConfig.rpcAddrs[0] + path; + const url = config.rpcAddrs[0] + path; // console.warn('fetching', url); return powers.fetch(url, { keepalive: true }).then(res => res.json()); }; + // height=0 is the same as omitting height and implies the highest block + const url = (path = 'published', { kind = 'children', height = 0 } = {}) => + `/abci_query?path=%22/custom/vstorage/${kind}/${path}%22&height=${height}`; + + const readStorage = (path = 'published', { kind = 'children', height = 0 }) => + getJSON(url(path, { kind, height })).catch(err => { + throw Error(`cannot read ${kind} of ${path}: ${err.message}`); + }); return { - // height=0 is the same as omitting height and implies the highest block - url: (path = 'published', { kind = 'children', height = 0 } = {}) => - `/abci_query?path=%22/custom/vstorage/${kind}/${path}%22&height=${height}`, + url, decode({ result: { response } }) { const { code } = response; if (code !== 0) { @@ -68,11 +91,11 @@ export const makeVStorage = powers => { * @returns {Promise} latest vstorage value at path */ async readLatest(path = 'published') { - const raw = await getJSON(this.url(path, { kind: 'data' })); + const raw = await readStorage(path, { kind: 'data' }); return this.decode(raw); }, async keys(path = 'published') { - const raw = await getJSON(this.url(path, { kind: 'children' })); + const raw = await readStorage(path, { kind: 'children' }); return JSON.parse(this.decode(raw)).children; }, /** @@ -81,7 +104,7 @@ export const makeVStorage = powers => { * @returns {Promise<{blockHeight: number, values: string[]}>} */ async readAt(path, height = undefined) { - const raw = await getJSON(this.url(path, { kind: 'data', height })); + const raw = await readStorage(path, { kind: 'data', height }); const txt = this.decode(raw); /** @type {{ value: string }} */ const { value } = JSON.parse(txt); @@ -98,25 +121,25 @@ export const makeVStorage = powers => { // undefined the first iteration, to query at the highest let blockHeight; do { - console.debug('READING', { blockHeight }); + // console.debug('READING', { blockHeight }); let values; try { // eslint-disable-next-line no-await-in-loop ({ blockHeight, values } = await this.readAt( path, - blockHeight && blockHeight - 1, + blockHeight && Number(blockHeight) - 1, )); - console.debug('readAt returned', { blockHeight }); + // console.debug('readAt returned', { blockHeight }); } catch (err) { if ('log' in err && err.log.match(/unknown request/)) { - console.error(err); + // console.error(err); break; } throw err; } parts.push(values); - console.debug('PUSHED', values); - console.debug('NEW', { blockHeight }); + // console.debug('PUSHED', values); + // console.debug('NEW', { blockHeight }); } while (blockHeight > 0); return parts.flat(); }, @@ -201,8 +224,8 @@ export const makeAgoricNames = async (ctx, vstorage) => { return { ...Object.fromEntries(entries), reverse }; }; -export const makeRpcUtils = async ({ fetch }) => { - const vstorage = makeVStorage({ fetch }); +export const makeRpcUtils = async ({ fetch }, config = networkConfig) => { + const vstorage = makeVStorage({ fetch }, config); const fromBoard = makeFromBoard(); const agoricNames = await makeAgoricNames(fromBoard, vstorage); diff --git a/packages/agoric-cli/src/lib/wallet.js b/packages/agoric-cli/src/lib/wallet.js index 07e296245d4..815f9c67e1c 100644 --- a/packages/agoric-cli/src/lib/wallet.js +++ b/packages/agoric-cli/src/lib/wallet.js @@ -24,37 +24,42 @@ export const getCurrent = async (addr, ctx, { vstorage }) => { return capDatas[0]; }; -/** @param {import('@agoric/smart-wallet/src/smartWallet').BridgeAction} bridgeAction */ -export const outputAction = bridgeAction => { +/** + * @param {import('@agoric/smart-wallet/src/smartWallet').BridgeAction} bridgeAction + * @param {Pick} [stdout] + */ +export const outputAction = (bridgeAction, stdout = process.stdout) => { const capData = marshaller.serialize(bridgeAction); - process.stdout.write(JSON.stringify(capData)); - process.stdout.write('\n'); + stdout.write(JSON.stringify(capData)); + stdout.write('\n'); }; /** * @param {import('@agoric/smart-wallet/src/offers.js').OfferSpec} offer + * @param {Pick} [stdout] */ -export const outputExecuteOfferAction = offer => { +export const outputExecuteOfferAction = (offer, stdout = process.stdout) => { /** @type {import('@agoric/smart-wallet/src/smartWallet').BridgeAction} */ const spendAction = { method: 'executeOffer', offer, }; - outputAction(spendAction); + outputAction(spendAction, stdout); }; /** * @deprecated use `.current` node for current state * @param {import('@agoric/casting').Follower>} follower + * @param {Brand<'set'>} [invitationBrand] */ -export const coalesceWalletState = async follower => { +export const coalesceWalletState = async (follower, invitationBrand) => { // values with oldest last const history = []; for await (const followerElement of iterateReverse(follower)) { history.push(followerElement.value); } - const coalescer = makeWalletStateCoalescer(); + const coalescer = makeWalletStateCoalescer(invitationBrand); // update with oldest first for (const record of history.reverse()) { coalescer.update(record); diff --git a/packages/agoric-cli/test/snapshots/test-inter-cli.js.md b/packages/agoric-cli/test/snapshots/test-inter-cli.js.md new file mode 100644 index 00000000000..15b2b75402f --- /dev/null +++ b/packages/agoric-cli/test/snapshots/test-inter-cli.js.md @@ -0,0 +1,111 @@ +# Snapshot report for `test/test-inter-cli.js` + +The actual snapshot is saved in `test-inter-cli.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## Usage: inter + +> Command usage: + + `Usage: agops inter [options] [command]␊ + ␊ + Inter Protocol tool␊ + ␊ + Options:␊ + --home [dir] agd CosmosSDK application home directory␊ + --keyring-backend [os|file|test] keyring's backend (os|file|test) (default␊ + "os")␊ + -h, --help display help for command␊ + ␊ + Commands:␊ + liquidation liquidation commands␊ + bid auction bidding commands␊ + reserve reserve commands␊ + help [command] display help for command` + +## Usage: inter liquidation status + +> Command usage: + + `Usage: agops inter liquidation status [options]␊ + ␊ + show amount liquidating, oracle price␊ + ␊ + For example:␊ + ␊ + {␊ + "liquidatingCollateral": "10IbcATOM",␊ + "liquidatingDebt": "120IST",␊ + "price": "12.00 IST/IbcATOM"␊ + }␊ + ␊ + ␊ + Options:␊ + --manager [number] Vault Manager (default: 0)␊ + -h, --help display help for command` + +## Usage: inter bid by-price + +> Command usage: + + `Usage: agops inter bid by-price [options]␊ + ␊ + Print an offer to bid collateral by price.␊ + ␊ + Options:␊ + --price [number] bid price␊ + --giveCurrency [number] Currency to give␊ + --wantCollateral [number] Collateral expected for the currency␊ + --collateralBrand [string] Collateral brand key (default: "IbcATOM")␊ + --offerId [number] Offer id (default: "bid-978307200000")␊ + -h, --help display help for command` + +## Usage: inter bid by-discount + +> Command usage: + + `Usage: agops inter bid by-discount [options]␊ + ␊ + Print an offer to bid on collateral based on discount from oracle price.␊ + ␊ + Options:␊ + --discount [percent] bid discount (0 to 100) or markup (0 to -100) %␊ + --giveCurrency [number] Currency to give␊ + --wantCollateral [number] bid price␊ + --collateralBrand [string] Collateral brand key (default: "IbcATOM")␊ + --offerId [number] Offer id (default: "bid-978307200000")␊ + -h, --help display help for command` + +## Usage: inter bid list + +> Command usage: + + `Usage: agops inter bid list [options]␊ + ␊ + Show status of bid offers.␊ + ␊ + For example:␊ + ␊ + $ inter bid list --from my-acct␊ + {"id":"bid-1679677228803","price":"9␊ + IST/IbcATOM","give":{"Currency":"50IST"},"want":"5IbcATOM"}␊ + {"id":"bid-1679677312341","discount":10,"give":{"Currency":"200IST"},"want":"1IbcATOM"}␊ + ␊ + Options:␊ + --from
wallet address literal or name␊ + -h, --help display help for command` + +## Usage: inter reserve add + +> Command usage: + + `Usage: agops inter reserve add [options]␊ + ␊ + add collateral to the reserve␊ + ␊ + Options:␊ + --giveCollateral [number] Collateral to give␊ + --collateralBrand [string] Collateral brand key (default: "IbcATOM")␊ + --offerId [number] Offer id (default: "bid-978307200000")␊ + -h, --help display help for command` diff --git a/packages/agoric-cli/test/snapshots/test-inter-cli.js.snap b/packages/agoric-cli/test/snapshots/test-inter-cli.js.snap new file mode 100644 index 00000000000..a7e05fc131f Binary files /dev/null and b/packages/agoric-cli/test/snapshots/test-inter-cli.js.snap differ diff --git a/packages/agoric-cli/test/test-inter-cli.js b/packages/agoric-cli/test/test-inter-cli.js new file mode 100644 index 00000000000..1b96e3cb686 --- /dev/null +++ b/packages/agoric-cli/test/test-inter-cli.js @@ -0,0 +1,347 @@ +// @ts-check +/* global Buffer */ +import '@endo/init'; +import test from 'ava'; +import { createCommand, CommanderError } from 'commander'; + +import { Far } from '@endo/far'; +import { boardSlottingMarshaller } from '../src/lib/rpc.js'; + +import { fmtBid, makeInterCommand } from '../src/commands/inter.js'; + +const { entries } = Object; + +const unused = (...args) => { + console.error('unused?', ...args); + assert.fail('should not be needed'); +}; + +/** @typedef {import('@agoric/vats/tools/board-utils.js').BoardRemote} BoardRemote */ + +/** + * @param {{ boardId: string, iface: string }} detail + * @returns {BoardRemote} + */ +const makeBoardRemote = ({ boardId, iface }) => + Far(iface, { getBoardId: () => boardId }); + +/** @type {Record & BoardRemote)>} */ +// @ts-expect-error mock +const topBrands = harden({ + ATOM: makeBoardRemote({ boardId: 'board00848', iface: 'Brand' }), + IST: makeBoardRemote({ boardId: 'board0566', iface: 'Brand' }), +}); + +const agoricNames = harden({ + brand: { IST: topBrands.IST, ATOM: topBrands.ATOM, IbcATOM: topBrands.ATOM }, + + instance: { + auctioneer: makeBoardRemote({ boardId: 'board434', iface: 'Instance' }), + }, + + /** @type {Record} */ + vbankAsset: { + uist: { + denom: 'uist', + brand: topBrands.IST, + displayInfo: { assetKind: 'nat', decimalPlaces: 6 }, + issuer: /** @type {any} */ ({}), + issuerName: 'IST', + proposedName: 'Agoric stable local currency', + }, + + 'ibc/toyatom': { + denom: 'ibc/toyatom', + brand: topBrands.ATOM, + displayInfo: { assetKind: 'nat', decimalPlaces: 6 }, + issuer: /** @type {any} */ ({}), + issuerName: 'ATOM', + proposedName: 'ATOM', + }, + }, +}); + +const bslot = { + ATOM: { '@qclass': 'slot', index: 0 }, + IST: { '@qclass': 'slot', index: 1 }, +}; +const qi = i => ({ '@qclass': 'bigint', digits: `${i}` }); +const mk = (brand, v) => ({ brand, value: qi(v) }); + +const offerSpec1 = harden({ + method: 'executeOffer', + offer: { + id: 'bid-978307200000', + invitationSpec: { + callPipe: [['makeBidInvitation', [bslot.ATOM]]], + instancePath: ['auctioneer'], + source: 'agoricContract', + }, + offerArgs: { + offerPrice: { + numerator: mk(bslot.IST, 9n), + denominator: mk(bslot.ATOM, 1n), + }, + want: mk(bslot.ATOM, 5000000n), + }, + proposal: { + exit: { onDemand: null }, + give: { + Currency: mk(bslot.IST, 50000000n), + }, + }, + }, +}); + +const publishedNames = { + agoricNames: { + brand: entries(agoricNames.brand), + instance: entries(agoricNames.instance), + vbankAsset: entries(agoricNames.vbankAsset), + }, +}; + +const makeNet = published => { + const encode = txt => { + const value = Buffer.from(txt).toString('base64'); + return { result: { response: { code: 0, value } } }; + }; + const m = boardSlottingMarshaller(); + const fmt = obj => { + const capData = m.serialize(obj); + const values = [JSON.stringify(capData)]; + const specimen = { blockHeight: undefined, values }; + const txt = JSON.stringify({ + value: JSON.stringify(specimen), + }); + return encode(txt); + }; + + /** @type {typeof fetch} */ + // @ts-expect-error mock + const fetch = async (url, _opts) => { + const matched = url.match( + /abci_query\?path=%22\/custom\/vstorage\/data\/published.(?[^%]+)%22/, + ); + if (!matched) throw Error(`fetch what?? ${url}`); + const { path } = matched.groups; + let data = published; + for (const key of path.split('.')) { + data = data[key]; + if (!data) throw Error(`query what?? ${path}`); + } + return harden({ + json: async () => fmt(data), + }); + }; + + return { fetch }; +}; + +const govKeyring = { + gov1: 'agoric1ldmtatp24qlllgxmrsjzcpe20fvlkp448zcuce', + gov2: 'agoric140dmkrz2e42ergjj7gyvejhzmjzurvqeq82ang', +}; + +const makeProcess = (t, keyring, out) => { + /** @type {typeof import('child_process').execFileSync} */ + // @ts-expect-error mock + const execFileSync = (file, args) => { + switch (file) { + case 'agd': { + t.deepEqual(args.slice(0, 3), ['keys', 'show', '--address']); + const name = args[3]; + const addr = keyring[name]; + if (!addr) { + throw Error(`no such key in keyring: ${name}`); + } + return addr; + } + default: + throw Error('not impl'); + } + }; + + const stdout = harden({ + write: x => { + out.push(x); + return true; + }, + }); + return { + env: {}, + stdout, + stderr: { write: _s => true }, + now: () => Date.parse('2001-01-01'), + createCommand, + execFileSync, + }; +}; + +test('inter bid place by-price: output is correct', async t => { + const argv = + 'node inter bid by-price --giveCurrency 50 --price 9 --wantCollateral 5' + .trim() + .split(' '); + + const out = []; + const cmd = await makeInterCommand( + { ...makeProcess(t, govKeyring, out), execFileSync: unused }, + makeNet(publishedNames), + ); + cmd.exitOverride(() => t.fail('exited')); + + await cmd.parseAsync(argv); + + const x = out.join('').trim(); + const { body, slots } = JSON.parse(x); + const o = JSON.parse(body); + + t.deepEqual(o, offerSpec1); + t.deepEqual( + slots, + [topBrands.ATOM, topBrands.IST].map(b => b.getBoardId()), + ); +}); + +/** + * @type {import('@agoric/smart-wallet/src/offers.js').OfferStatus & + * { offerArgs: import('@agoric/inter-protocol/src/auction/auctionBook.js').BidSpec}} + */ +const offerStatus1 = harden({ + error: 'Error: "nameKey" not found: (a string)', + id: 1678990150266, + invitationSpec: { + callPipe: [['makeBidInvitation', [topBrands.ATOM]]], + instancePath: ['auctioneer'], + source: 'agoricContract', + }, + offerArgs: { + offerPrice: { + denominator: { brand: topBrands.ATOM, value: 2000000n }, + numerator: { brand: topBrands.IST, value: 20000000n }, + }, + want: { brand: topBrands.ATOM, value: 2000000n }, + }, + proposal: { + give: { + Currency: { brand: topBrands.ATOM, value: 20000000n }, + }, + }, +}); + +/** + * @type {import('@agoric/smart-wallet/src/offers.js').OfferStatus & + * { offerArgs: import('@agoric/inter-protocol/src/auction/auctionBook.js').BidSpec}} + */ +const offerStatus2 = harden({ + id: 'bid-234234', + invitationSpec: { + callPipe: [['makeBidInvitation', [topBrands.ATOM]]], + instancePath: ['auctioneer'], + source: 'agoricContract', + }, + offerArgs: { + offerBidScaling: { + denominator: { brand: topBrands.IST, value: 100n }, + numerator: { brand: topBrands.IST, value: 90n }, + }, + want: { brand: topBrands.ATOM, value: 2000000n }, + }, + proposal: { + give: { + Currency: { brand: topBrands.ATOM, value: 20000000n }, + }, + }, + payouts: { + Collateral: { brand: topBrands.ATOM, value: 5_000_000n }, + Currency: { brand: topBrands.IST, value: 37_000_000n }, + }, +}); + +test('inter bid list: finds one bid', async t => { + const argv = 'node inter bid list --from gov1'.split(' '); + + const wallet = { + [govKeyring.gov1]: { updated: 'offerStatus', status: offerStatus2 }, + [govKeyring.gov2]: { updated: 'XXX' }, + }; + + const out = []; + + const cmd = await makeInterCommand( + makeProcess(t, govKeyring, out), + makeNet({ ...publishedNames, wallet }), + ); + cmd.exitOverride(() => t.fail('exited')); + + await cmd.parseAsync(argv); + t.deepEqual( + out.join('').trim(), + JSON.stringify({ + id: 'bid-234234', + discount: 10, + give: { Currency: '20ATOM' }, + want: '2ATOM', + payouts: { Collateral: '5ATOM', Currency: '37IST' }, + }), + ); +}); + +const subCommands = c => [c, ...c.commands.flatMap(subCommands)]; + +const usageTest = (words, blurb = 'Command usage:') => { + test(`Usage: ${words}`, async t => { + const argv = `node agops ${words} --help`.split(' '); + + const out = []; + const program = createCommand('agops'); + const cmd = await makeInterCommand(makeProcess(t, {}, out), makeNet({})); + program.addCommand(cmd); + const cs = subCommands(program); + cs.forEach(c => + c.exitOverride(() => { + throw new CommanderError(1, 'usage', ''); + }), + ); + cmd.configureOutput({ + writeOut: s => out.push(s), + writeErr: s => out.push(s), + }); + + await t.throwsAsync(program.parseAsync(argv), { + instanceOf: CommanderError, + }); + t.snapshot(out.join('').trim(), blurb); + }); +}; +usageTest('inter'); +usageTest('inter liquidation status'); +usageTest('inter bid by-price'); +usageTest('inter bid by-discount'); +usageTest('inter bid list'); +usageTest('inter reserve add'); + +test('formatBid', t => { + const { values } = Object; + { + const actual = fmtBid(offerStatus1, values(agoricNames.vbankAsset)); + t.deepEqual(actual, { + id: 1678990150266, + error: 'Error: "nameKey" not found: (a string)', + give: { Currency: '20ATOM' }, + price: '10 IST/ATOM', + want: '2ATOM', + }); + } + { + const actual = fmtBid(offerStatus2, values(agoricNames.vbankAsset)); + t.deepEqual(actual, { + id: 'bid-234234', + give: { Currency: '20ATOM' }, + payouts: { Collateral: '5ATOM', Currency: '37IST' }, + want: '2ATOM', + discount: 10, + }); + } +}); diff --git a/packages/inter-protocol/src/clientSupport.js b/packages/inter-protocol/src/clientSupport.js index 945da6f5211..785a8aeba00 100644 --- a/packages/inter-protocol/src/clientSupport.js +++ b/packages/inter-protocol/src/clientSupport.js @@ -212,15 +212,51 @@ const makePsmSwapOffer = (instance, brands, opts) => { /** * @param {Record} brands - * @param {{ offerId: string, wantCollateral: number, giveCurrency: number, collateralBrandKey: string }} opts + * @param {{ + * offerId: string, + * collateralBrandKey: string, + * giveCurrency: number, + * wantCollateral: number, + * } & ({ + * price: number, + * } | { + * discount: number, // -1 to 1. e.g. 0.10 for 10% discount, -0.05 for 5% markup + * })} opts * @returns {import('@agoric/smart-wallet/src/offers.js').OfferSpec} */ const makeBidOffer = (brands, opts) => { const give = { Currency: AmountMath.make(brands.IST, scaleDecimals(opts.giveCurrency)), }; + /** @type {Brand<'nat'>} */ + // @ts-expect-error XXX how to narrow AssetKind? const collateralBrand = brands[opts.collateralBrandKey]; + const want = AmountMath.make( + collateralBrand, + scaleDecimals(opts.wantCollateral), + ); + + const bounds = (x, lo, hi) => { + assert(x >= lo && x <= hi); + return x; + }; + + /** @type {import('./auction/auctionBook.js').BidSpec} */ + const offerArgs = + 'price' in opts + ? { + want, + offerPrice: parseRatio(opts.price, brands.IST, collateralBrand), + } + : { + want, + offerBidScaling: parseRatio( + (1 - bounds(opts.discount, -1, 1)).toFixed(2), + brands.IST, + brands.IST, + ), + }; /** @type {import('@agoric/smart-wallet/src/offers.js').OfferSpec} */ const offerSpec = { id: opts.offerId, @@ -230,17 +266,42 @@ const makeBidOffer = (brands, opts) => { callPipe: [['makeBidInvitation', [collateralBrand]]], }, proposal: { give, exit: { onDemand: null } }, - offerArgs: /** @type {import('./auction/auctionBook.js').BidSpec} */ ({ - want: AmountMath.make( - collateralBrand, - scaleDecimals(opts.wantCollateral), - ), - // FIXME hard-coded - offerBidScaling: parseRatio(1.1, brands.IST, brands.IST), - }), + offerArgs, }; return offerSpec; }; + +/** + * @param {Record} brands + * @param {{ + * offerId: string, + * giveCollateral: number, + * collateralBrandKey: string, + * }} opts + * @returns {import('@agoric/smart-wallet/src/offers.js').OfferSpec} + */ +const makeAddCollateralOffer = (brands, opts) => { + /** @type {AmountKeywordRecord} */ + const give = { + Collateral: AmountMath.make( + brands[opts.collateralBrandKey], + scaleDecimals(opts.giveCollateral), + ), + }; + + /** @type {import('@agoric/smart-wallet/src/offers.js').OfferSpec} */ + const offerSpec = { + id: opts.offerId, + invitationSpec: { + source: 'agoricContract', + instancePath: ['reserve'], + callPipe: [['makeAddCollateralInvitation', []]], + }, + proposal: { give }, + }; + return offerSpec; +}; + export const Offers = { auction: { Bid: makeBidOffer, @@ -254,4 +315,7 @@ export const Offers = { AdjustBalances: makeAdjustOffer, CloseVault: makeCloseOffer, }, + reserve: { + AddCollateral: makeAddCollateralOffer, + }, }; diff --git a/packages/inter-protocol/test/test-clientSupport.js b/packages/inter-protocol/test/test-clientSupport.js index df6e255eaf8..52783bc70c7 100644 --- a/packages/inter-protocol/test/test-clientSupport.js +++ b/packages/inter-protocol/test/test-clientSupport.js @@ -1,6 +1,7 @@ import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import { makeIssuerKit } from '@agoric/ertp'; +import { makeRatio } from '@agoric/zoe/src/contractSupport/ratio.js'; import { withAmountUtils } from './supports.js'; import { Offers } from '../src/clientSupport.js'; @@ -13,15 +14,55 @@ const brands = { }; test('Offers.auction.Bid', async t => { + const discounts = [ + { cliArg: 0.05, offerBidScaling: makeRatio(95n, ist.brand, 100n) }, + { cliArg: 0.95, offerBidScaling: makeRatio(5n, ist.brand, 100n) }, + { cliArg: -0.05, offerBidScaling: makeRatio(105n, ist.brand, 100n) }, + { cliArg: -0.1, offerBidScaling: makeRatio(110n, ist.brand, 100n) }, + ]; + + discounts.forEach(({ cliArg, offerBidScaling }) => { + t.log('discount', cliArg * 100, '%'); + t.deepEqual( + Offers.auction.Bid(brands, { + offerId: 'foo1', + wantCollateral: 1.23, + giveCurrency: 4.56, + collateralBrandKey: 'ATOM', + discount: cliArg, + }), + { + id: 'foo1', + invitationSpec: { + source: 'agoricContract', + instancePath: ['auctioneer'], + callPipe: [['makeBidInvitation', [atom.brand]]], + }, + proposal: { + exit: { onDemand: null }, + give: { Currency: ist.make(4_560_000n) }, + }, + offerArgs: { + offerBidScaling, + want: atom.make(1_230_000n), + }, + }, + ); + }); + + const price = 7; + const offerPrice = makeRatio(7n, ist.brand, 1n, atom.brand); + t.log({ price, offerPrice }); t.deepEqual( Offers.auction.Bid(brands, { - offerId: 'foo1', + offerId: 'by-price2', wantCollateral: 1.23, giveCurrency: 4.56, collateralBrandKey: 'ATOM', + price, }), { - id: 'foo1', + id: 'by-price2', invitationSpec: { source: 'agoricContract', instancePath: ['auctioneer'], @@ -32,10 +73,7 @@ test('Offers.auction.Bid', async t => { give: { Currency: ist.make(4_560_000n) }, }, offerArgs: { - offerBidScaling: { - denominator: ist.make(10n), - numerator: ist.make(11n), - }, + offerPrice, want: atom.make(1_230_000n), }, }, diff --git a/packages/vats/test/bootstrapTests/test-vaults-integration.js b/packages/vats/test/bootstrapTests/test-vaults-integration.js index c6b49cf80a3..81d440f3142 100644 --- a/packages/vats/test/bootstrapTests/test-vaults-integration.js +++ b/packages/vats/test/bootstrapTests/test-vaults-integration.js @@ -269,6 +269,7 @@ test('exit bid', async t => { wantCollateral: 1.23, giveCurrency: 0.1, collateralBrandKey: 'IbcATOM', + price: 5, }); await wd.tryExitOffer('bid');