diff --git a/packages/agoric-cli/src/bin-agops.js b/packages/agoric-cli/src/bin-agops.js index 21b96dc83a0..1c1bf57721b 100755 --- a/packages/agoric-cli/src/bin-agops.js +++ b/packages/agoric-cli/src/bin-agops.js @@ -1,7 +1,7 @@ #!/usr/bin/env node // @ts-check /* eslint-disable @jessie.js/no-nested-await */ -/* global fetch */ +/* global fetch, setTimeout */ import '@agoric/casting/node-fetch-shim.js'; import '@endo/init'; @@ -19,6 +19,7 @@ import { makeReserveCommand } from './commands/reserve.js'; import { makeVaultsCommand } from './commands/vaults.js'; import { makePerfCommand } from './commands/perf.js'; import { makeInterCommand } from './commands/inter.js'; +import { makeAuctionCommand } from './commands/auction.js'; const logger = anylogger('agops'); const progname = path.basename(process.argv[1]); @@ -26,26 +27,48 @@ const progname = path.basename(process.argv[1]); const program = new Command(); program.name(progname).version('unversioned'); -program.addCommand(await makeOracleCommand(logger)); -program.addCommand(await makeEconomicCommiteeCommand(logger)); -program.addCommand(await makePerfCommand(logger)); -program.addCommand(await makePsmCommand(logger)); -program.addCommand(await makeReserveCommand(logger)); -program.addCommand(await makeVaultsCommand(logger)); - -program.addCommand( - await makeInterCommand( - { - env: { ...process.env }, - stdout: process.stdout, - stderr: process.stderr, - createCommand, - execFileSync, - now: () => Date.now(), - }, - { fetch }, - ), -); +program.addCommand(makeOracleCommand(logger)); +program.addCommand(makeEconomicCommiteeCommand(logger)); +program.addCommand(makePerfCommand(logger)); +program.addCommand(makePsmCommand(logger)); +program.addCommand(makeVaultsCommand(logger)); + +/** + * XXX Threading I/O powers has gotten a bit jumbled. + * + * Perhaps a more straightforward approach would be: + * + * - makeTUI({ stdout, stderr, logger }) + * where tui.show(data) prints data as JSON to stdout + * and tui.warn() and tui.error() log ad-hoc to stderr + * - makeQueryClient({ fetch }) + * with q.withConfig(networkConfig) + * and q.vstorage.get('published...') (no un-marshaling) + * and q.pollBlocks(), q.pollTx() + * also, printing the progress message should be done + * in the lookup callback + * - makeBoardClient(queryClient) + * with b.readLatestHead('published...') + * - makeKeyringNames({ execFileSync }) + * with names.lookup('gov1') -> 'agoric1...' + * and names.withBackend('test') + * and names.withHome('~/.agoric') + * - makeSigner({ execFileSync }) + * signer.sendSwingsetTx() + */ +const procIO = { + env: { ...process.env }, + stdout: process.stdout, + stderr: process.stderr, + createCommand, + execFileSync, + now: () => Date.now(), + setTimeout, +}; + +program.addCommand(makeReserveCommand(logger, procIO)); +program.addCommand(makeAuctionCommand(logger, { ...procIO, fetch })); +program.addCommand(makeInterCommand(procIO, { fetch })); try { await program.parseAsync(process.argv); diff --git a/packages/agoric-cli/src/commands/auction.js b/packages/agoric-cli/src/commands/auction.js new file mode 100644 index 00000000000..0bf9405ddb3 --- /dev/null +++ b/packages/agoric-cli/src/commands/auction.js @@ -0,0 +1,166 @@ +/* eslint-disable @jessie.js/no-nested-await */ +// @ts-check +/* eslint-disable func-names */ +import { InvalidArgumentError } from 'commander'; +import { makeRpcUtils } from '../lib/rpc.js'; +import { outputActionAndHint } from '../lib/wallet.js'; + +const { Fail } = assert; + +/** @typedef {import('@agoric/governance/src/contractGovernance/typedParamManager.js').ParamTypesMap} ParamTypesMap */ +/** @template M @typedef {import('@agoric/governance/src/contractGovernance/typedParamManager.js').ParamTypesMapFromRecord} ParamTypesMapFromRecord */ + +/** + * @template {ParamTypesMap} M + * @typedef {{ + * [K in keyof M]: ParamValueForType + * }} ParamValues + */ + +/** @typedef {ReturnType} AuctionParamRecord */ +/** @typedef {ParamValues>} AuctionParams */ + +/** + * @param {import('anylogger').Logger} _logger + * @param {{ + * createCommand: typeof import('commander').createCommand, + * fetch: typeof window.fetch, + * stdout: Pick, + * stderr: Pick, + * now: () => number, + * }} io + */ +export const makeAuctionCommand = ( + _logger, + { createCommand, stdout, stderr, fetch, now }, +) => { + const auctioneer = createCommand('auctioneer').description( + 'Auctioneer commands', + ); + + auctioneer + .command('proposeParamChange') + .description('propose a change to start frequency') + .option( + '--start-frequency ', + 'how often to start auctions', + BigInt, + ) + .option('--clock-step ', 'descending clock frequency', BigInt) + .option( + '--starting-rate ', + 'relative to oracle: 999 = 1bp discount', + BigInt, + ) + .option('--lowest-rate ', 'lower limit for discount', BigInt) + .option( + '--discount-step ', + 'descending clock step size', + BigInt, + ) + .option( + '--discount-step ', + 'proposed value (basis points)', + BigInt, + ) + .requiredOption( + '--charterAcceptOfferId ', + 'offer that had continuing invitation result', + ) + .option('--offer-id ', 'Offer id', String, `propose-${Date.now()}`) + .option( + '--deadline [minutes]', + 'minutes from now to close the vote', + Number, + 1, + ) + .action( + /** + * + * @param {{ + * charterAcceptOfferId: string, + * startFrequency?: bigint, + * clockStep?: bigint, + * startingRate?: bigint, + * lowestRate?: bigint, + * discountStep?: bigint, + * offerId: string, + * deadline: number, + * }} opts + */ + async opts => { + const { agoricNames, readLatestHead } = await makeRpcUtils({ fetch }); + + /** @type {{ current: AuctionParamRecord }} */ + // @ts-expect-error XXX should runtime check? + const { current } = await readLatestHead( + `published.auction.governance`, + ); + + const { + AuctionStartDelay: { + // @ts-expect-error XXX RelativeTime includes raw bigint + value: { timerBrand }, + }, + } = current; + timerBrand || Fail`no timer brand?`; + + /** + * typed param manager requires RelativeTimeRecord + * but TimeMath.toRel prodocues a RelativeTime (which may be a bare bigint). + * + * @param {bigint} relValue + * @returns {import('@agoric/time/src/types').RelativeTimeRecord} + */ + const toRel = relValue => ({ timerBrand, relValue }); + + /** @type {Partial} */ + const params = { + ...(opts.startFrequency && { + StartFrequency: toRel(opts.startFrequency), + }), + ...(opts.clockStep && { ClockStep: toRel(opts.clockStep) }), + ...(opts.startingRate && { StartingRate: opts.startingRate }), + ...(opts.lowestRate && { LowestRate: opts.lowestRate }), + ...(opts.discountStep && { DiscountStep: opts.discountStep }), + }; + + if (Object.keys(params).length === 0) { + throw new InvalidArgumentError(`no parameters given`); + } + + const instance = agoricNames.instance.auctioneer; + instance || Fail`missing auctioneer in names`; + + const t0 = now(); + const deadline = BigInt(Math.round(t0 / 1000) + 60 * opts.deadline); + + /** @type {import('@agoric/inter-protocol/src/econCommitteeCharter.js').ParamChangesOfferArgs} */ + const offerArgs = { + deadline, + params, + instance, + path: { paramPath: { key: 'governedParams' } }, + }; + + /** @type {import('@agoric/smart-wallet/src/offers.js').OfferSpec} */ + const offer = { + id: opts.offerId, + invitationSpec: { + source: 'continuing', + previousOffer: opts.charterAcceptOfferId, + invitationMakerName: 'VoteOnParamChange', + }, + offerArgs, + proposal: {}, + }; + + outputActionAndHint( + { method: 'executeOffer', offer }, + { stdout, stderr }, + ); + }, + ); + + return auctioneer; +}; diff --git a/packages/agoric-cli/src/commands/ec.js b/packages/agoric-cli/src/commands/ec.js index 3e2d19f642d..379754b7d4b 100644 --- a/packages/agoric-cli/src/commands/ec.js +++ b/packages/agoric-cli/src/commands/ec.js @@ -1,126 +1,280 @@ /* eslint-disable @jessie.js/no-nested-await */ // @ts-check /* eslint-disable func-names */ -/* global fetch */ -import { Command } from 'commander'; -import { makeRpcUtils, storageHelper } from '../lib/rpc.js'; -import { outputExecuteOfferAction } from '../lib/wallet.js'; +/* global globalThis, process, setTimeout */ +import { execFileSync as execFileSyncAmbient } from 'child_process'; +import { Command, CommanderError } from 'commander'; +import { normalizeAddressWithOptions, pollBlocks } from '../lib/chain.js'; +import { getNetworkConfig, makeRpcUtils } from '../lib/rpc.js'; +import { + getCurrent, + getLastUpdate, + outputActionAndHint, + sendAction, + findContinuingIds, +} from '../lib/wallet.js'; + +/** @typedef {import('@agoric/smart-wallet/src/offers.js').OfferSpec} OfferSpec */ /** * * @param {import('anylogger').Logger} _logger + * @param {{ + * env?: Record, + * fetch?: typeof window.fetch, + * stdout?: Pick, + * stderr?: Pick, + * execFileSync?: typeof execFileSyncAmbient, + * delay?: (ms: number) => Promise, + * }} [io] */ -export const makeEconomicCommiteeCommand = async _logger => { +export const makeEconomicCommiteeCommand = (_logger, io = {}) => { + const { + // Allow caller to provide access explicitly, but + // default to conventional ambient IO facilities. + env = process.env, + stdout = process.stdout, + stderr = process.stderr, + fetch = globalThis.fetch, + execFileSync = execFileSyncAmbient, + delay = ms => new Promise(resolve => setTimeout(resolve, ms)), + } = io; + const ec = new Command('ec').description('Economic Committee commands'); + /** @param {string} literalOrName */ + const normalizeAddress = literalOrName => + normalizeAddressWithOptions(literalOrName, { keyringBackend: 'test' }); + + /** @type {(info: unknown, indent?: unknown) => boolean } */ + const show = (info, indent) => + stdout.write(`${JSON.stringify(info, null, indent ? 2 : undefined)}\n`); + + const abortIfSeen = (instanceName, found) => { + const done = found.filter(it => it.instanceName === instanceName); + if (done.length > 0) { + console.warn(`invitation to ${instanceName} already accepted`, done); + throw new CommanderError(1, 'EALREADY', `already accepted`); + } + }; + + /** + * Make an offer from agoricNames, wallet status; sign and broadcast it, + * given a sendFrom address; else print it. + * + * @param {{ + * toOffer: (agoricNames: *, current: *) => OfferSpec, + * sendFrom: string, + * instanceName?: string, + * }} detail + * @param {Awaited>} [optUtils] + */ + const processOffer = async function ({ toOffer, sendFrom }, optUtils) { + const networkConfig = await getNetworkConfig(env); + const utils = await (optUtils || makeRpcUtils({ fetch })); + const { agoricNames, readLatestHead } = utils; + + let current; + if (sendFrom) { + current = await getCurrent(sendFrom, { readLatestHead }); + } + + const offer = toOffer(agoricNames, current); + if (!sendFrom) { + outputActionAndHint( + { method: 'executeOffer', offer }, + { stdout, stderr }, + ); + return; + } + + const result = await sendAction( + { method: 'executeOffer', offer }, + { + keyring: { backend: 'test' }, // XXX + from: sendFrom, + verbose: false, + ...networkConfig, + execFileSync, + stdout, + delay, + }, + ); + const { timestamp, txhash, height } = result; + console.error('wallet action is broadcast:'); + show({ timestamp, height, offerId: offer.id, txhash }); + const checkInWallet = async blockInfo => { + const [state, update] = await Promise.all([ + getCurrent(sendFrom, { readLatestHead }), + getLastUpdate(sendFrom, { readLatestHead }), + readLatestHead(`published.wallet.${sendFrom}`), + ]); + if (update.updated === 'offerStatus' && update.status.id === offer.id) { + return blockInfo; + } + const info = await findContinuingIds(state, agoricNames); + const done = info.filter(it => it.offerId === offer.id); + if (!(done.length > 0)) throw Error('retry'); + return blockInfo; + }; + const blockInfo = await pollBlocks({ + retryMessage: 'offer not yet in block', + ...networkConfig, + execFileSync, + delay, + })(checkInWallet); + console.error('offer accepted in block'); + show(blockInfo); + }; + ec.command('committee') .description('accept invitation to join the economic committee') + .option('--voter [number]', 'Voter number', Number, 0) .option( '--offerId [string]', 'Offer id', String, `ecCommittee-${Date.now()}`, ) + .option( + '--send-from ', + 'Send from address', + normalizeAddress, + ) .action(async function (opts) { - const { agoricNames } = await makeRpcUtils({ fetch }); - - const { economicCommittee } = agoricNames.instance; - assert(economicCommittee, 'missing economicCommittee'); - - /** @type {import('@agoric/smart-wallet/src/offers.js').OfferSpec} */ - const offer = { - id: opts.offerId, - invitationSpec: { - source: 'purse', - instance: economicCommittee, - description: 'Voter0', // XXX it may not always be - }, - proposal: {}, - }; + /** @type {(a: *, c: *) => OfferSpec} */ + const toOffer = (agoricNames, current) => { + const instance = agoricNames.instance.economicCommittee; + assert(instance, `missing economicCommittee`); + + const found = findContinuingIds(current, agoricNames); + abortIfSeen('economicCommittee', found); - outputExecuteOfferAction(offer); + return { + id: opts.offerId, + invitationSpec: { + source: 'purse', + instance, + description: `Voter${opts.voter}`, + }, + proposal: {}, + }; + }; - console.warn('Now execute the prepared offer'); + await processOffer({ + toOffer, + instanceName: 'economicCommittee', + ...opts, + }); }); ec.command('charter') - .description('prepare an offer to accept the charter invitation') + .description('accept the charter invitation') .option('--offerId [string]', 'Offer id', String, `ecCharter-${Date.now()}`) + .option( + '--send-from ', + 'Send from address', + normalizeAddress, + ) .action(async function (opts) { - const { agoricNames } = await makeRpcUtils({ fetch }); - - const { econCommitteeCharter } = agoricNames.instance; - assert(econCommitteeCharter, 'missing econCommitteeCharter'); - - /** @type {import('@agoric/smart-wallet/src/offers.js').OfferSpec} */ - const offer = { - id: opts.offerId, - invitationSpec: { - source: 'purse', - instance: econCommitteeCharter, - description: 'charter member invitation', - }, - proposal: {}, + /** @type {(a: *, c: *) => OfferSpec} */ + const toOffer = (agoricNames, current) => { + const instance = agoricNames.instance.econCommitteeCharter; + assert(instance, `missing econCommitteeCharter`); + + const found = findContinuingIds(current, agoricNames); + abortIfSeen('econCommitteeCharter', found); + + return { + id: opts.offerId, + invitationSpec: { + source: 'purse', + instance, + description: 'charter member invitation', + }, + proposal: {}, + }; }; - outputExecuteOfferAction(offer); + await processOffer({ + toOffer, + instanceName: 'econCommitteeCharter', + ...opts, + }); + }); + + ec.command('find-continuing-ids') + .description('find ids of proposing, voting continuing invitations') + .requiredOption( + '--from ', + 'from address', + normalizeAddress, + ) + .action(async opts => { + const { agoricNames, readLatestHead } = await makeRpcUtils({ fetch }); + const current = await getCurrent(opts.from, { readLatestHead }); - console.warn('Now execute the prepared offer'); + const found = findContinuingIds(current, agoricNames); + found.forEach(it => show({ ...it, address: opts.from })); }); ec.command('vote') .description('vote on a question (hard-coded for now))') .option('--offerId [number]', 'Offer id', String, `ecVote-${Date.now()}`) - .requiredOption( - '--econCommAcceptOfferId [string]', - 'offer that had continuing invitation result', - ) .requiredOption( '--forPosition [number]', 'index of one position to vote for (within the question description.positions); ', Number, ) + .option( + '--send-from ', + 'Send from address', + normalizeAddress, + ) .action(async function (opts) { - const { vstorage, fromBoard } = await makeRpcUtils({ fetch }); + const utils = await makeRpcUtils({ fetch }); + const { readLatestHead } = utils; - const questionHandleCapDataStr = await vstorage.readLatest( + const info = await readLatestHead( 'published.committees.Economic_Committee.latestQuestion', ); - const questionDescriptions = storageHelper.unserializeTxt( - questionHandleCapDataStr, - fromBoard, - ); + // XXX runtime shape-check + const questionDesc = /** @type {any} */ (info); - assert(questionDescriptions, 'missing questionDescriptions'); - assert( - questionDescriptions.length === 1, - 'multiple questions not supported', - ); - - const questionDesc = questionDescriptions[0]; // TODO support multiple position arguments const chosenPositions = [questionDesc.positions[opts.forPosition]]; assert(chosenPositions, `undefined position index ${opts.forPosition}`); - /** @type {import('@agoric/smart-wallet/src/offers.js').OfferSpec} */ - const offer = { - id: opts.offerId, - invitationSpec: { - source: 'continuing', - previousOffer: opts.econCommAcceptOfferId, - invitationMakerName: 'makeVoteInvitation', - // (positionList, questionHandle) - invitationArgs: harden([ - chosenPositions, - questionDesc.questionHandle, - ]), - }, - proposal: {}, + /** @type {(a: *, c: *) => OfferSpec} */ + const toOffer = (agoricNames, current) => { + const cont = findContinuingIds(current, agoricNames); + const votingRight = cont.find( + it => it.instance === agoricNames.instance.economicCommittee, + ); + if (!votingRight) { + throw new CommanderError( + 1, + 'NO_INVITATION', + 'first, try: agops ec committee ...', + ); + } + return { + id: opts.offerId, + invitationSpec: { + source: 'continuing', + previousOffer: votingRight.offerId, + invitationMakerName: 'makeVoteInvitation', + // (positionList, questionHandle) + invitationArgs: harden([ + chosenPositions, + questionDesc.questionHandle, + ]), + }, + proposal: {}, + }; }; - outputExecuteOfferAction(offer); - - console.warn('Now execute the prepared offer'); + await processOffer({ toOffer, sendFrom: opts.sendFrom }, utils); }); return ec; diff --git a/packages/agoric-cli/src/commands/inter.js b/packages/agoric-cli/src/commands/inter.js index 1b445e13034..b4bb2477dfc 100644 --- a/packages/agoric-cli/src/commands/inter.js +++ b/packages/agoric-cli/src/commands/inter.js @@ -1,27 +1,50 @@ +/** + * @file Inter Protocol Liquidation Bidding CLI + * @see {makeInterCommand} for main function + */ + // @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, -} from '../lib/rpc.js'; -import { outputExecuteOfferAction } from '../lib/wallet.js'; -import { normalizeAddressWithOptions } from '../lib/chain.js'; +import { Offers } from '@agoric/inter-protocol/src/clientSupport.js'; +import { objectMap } from '@agoric/internal'; +import { M, matches } from '@agoric/store'; +import { normalizeAddressWithOptions, pollBlocks } from '../lib/chain.js'; import { asBoardRemote, bigintReplacer, makeAmountFormatter, } from '../lib/format.js'; +import { getNetworkConfig } from '../lib/rpc.js'; +import { + getCurrent, + makeParseAmount, + makeWalletUtils, + outputActionAndHint, + sendAction, +} from '../lib/wallet.js'; const { values } = Object; +const bidInvitationShape = harden({ + source: 'agoricContract', + instancePath: ['auctioneer'], + callPipe: [['makeBidInvitation', M.any()]], +}); + +/** + * Contract keywords. + * "Currency" is scheduled to be renamed to something like Bid. (#7284) + */ +export const KW = /** @type {const} */ { + Bid: 'Currency', + Collateral: 'Collateral', +}; + /** @typedef {import('@agoric/vats/tools/board-utils.js').VBankAssetDetail } AssetDescriptor */ +/** @typedef {import('@agoric/smart-wallet/src/smartWallet').TryExitOfferAction } TryExitOfferAction */ +/** @typedef {import('@agoric/inter-protocol/src/auction/auctionBook.js').BidSpec} BidSpec */ /** * Format amounts, prices etc. based on brand board Ids, displayInfo @@ -46,6 +69,13 @@ const makeFormatters = assets => { return { amount, record, price, discount }; }; +/** + * Format amounts in vaultManager metrics for JSON output. + * + * @param {*} metrics manager0.metrics + * @param {*} quote manager0.quote + * @param {*} assets agoricNames.vbankAssets + */ const fmtMetrics = (metrics, quote, assets) => { const fmt = makeFormatters(assets); const { liquidatingCollateral, liquidatingDebt } = metrics; @@ -65,10 +95,45 @@ const fmtMetrics = (metrics, quote, assets) => { }; /** - * Format amounts etc. in a bid OfferStatus + * Dynamic check that an OfferStatus is also a BidSpec. + * + * @param {import('@agoric/smart-wallet/src/offers.js').OfferStatus} offerStatus + * @param {Awaited>} agoricNames + * @param {typeof console.warn} warn + * returns null if offerStatus is not a BidSpec + */ +const coerceBid = (offerStatus, agoricNames, warn) => { + const { offerArgs } = offerStatus; + /** @type {unknown} */ + const collateralBrand = /** @type {any} */ (offerArgs)?.want?.brand; + if (!collateralBrand) { + warn('mal-formed bid offerArgs', offerStatus.id, 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: BidSpec}} + */ + // @ts-expect-error dynamic cast + const bid = offerStatus; + return bid; +}; + +/** + * Format amounts etc. in a BidSpec OfferStatus * * @param {import('@agoric/smart-wallet/src/offers.js').OfferStatus & - * { offerArgs: import('@agoric/inter-protocol/src/auction/auctionBook.js').BidSpec}} bid + * { offerArgs: BidSpec}} bid * @param {import('agoric/src/lib/format.js').AssetDescriptor[]} assets */ export const fmtBid = (bid, assets) => { @@ -83,21 +148,26 @@ export const fmtBid = (bid, assets) => { const { id, - error, proposal: { give }, offerArgs: { want }, payouts, + result, + error, } = bid; + const resultProp = + !error && result && result !== 'UNPUBLISHED' ? { result } : {}; const props = { ...(give ? { give: fmt.record(give) } : {}), ...(want ? { want: fmt.amount(want) } : {}), - ...(payouts ? { payouts: fmt.record(payouts) } : {}), + ...(payouts ? { payouts: fmt.record(payouts) } : resultProp), ...(error ? { error } : {}), }; return harden({ id, ...spec, ...props }); }; /** + * Make Inter Protocol liquidation bidding commands. + * * @param {{ * env: Partial>, * stdout: Pick, @@ -105,35 +175,64 @@ export const fmtBid = (bid, assets) => { * now: () => number, * createCommand: // Note: includes access to process.stdout, .stderr, .exit * typeof import('commander').createCommand, - * execFileSync: typeof import('child_process').execFileSync + * execFileSync: typeof import('child_process').execFileSync, + * setTimeout: typeof setTimeout, * }} process * @param {{ fetch: typeof window.fetch }} net */ -export const makeInterCommand = async ( - { env, stdout, stderr, now, execFileSync, createCommand }, +export const makeInterCommand = ( + { + env, + stdout, + stderr, + now, + setTimeout, + execFileSync: rawExec, + createCommand, + }, { fetch }, ) => { const interCmd = createCommand('inter') - .description('Inter Protocol tool') - .option('--home [dir]', 'agd CosmosSDK application home directory') + .description('Inter Protocol commands for liquidation bidding etc.') + .option('--home ', 'agd CosmosSDK application home directory') .option( '--keyring-backend [os|file|test]', - 'keyring\'s backend (os|file|test) (default "os")', + `keyring's backend (os|file|test) (default "${ + env.AGORIC_KEYRING_BACKEND || 'os' + }")`, + env.AGORIC_KEYRING_BACKEND, ); - const rpcTools = async () => { - const networkConfig = await getNetworkConfig(env); - const { agoricNames, fromBoard, readLatestHead, vstorage } = - await makeRpcUtils({ fetch }, networkConfig).catch(err => { - throw new CommanderError(1, 'RPC_FAIL', err.message); - }); - return { - networkConfig, - agoricNames, - fromBoard, - vstorage, - readLatestHead, - }; + /** @type {typeof import('child_process').execFileSync} */ + // @ts-expect-error execFileSync is overloaded + const execFileSync = (file, args, ...opts) => { + try { + return rawExec(file, args, ...opts); + } catch (err) { + throw new InvalidArgumentError( + `${err.message}: is ${file} in your $PATH?`, + ); + } + }; + + /** @param {number} ms */ + const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + const show = (info, indent = false) => + stdout.write( + `${JSON.stringify(info, bigintReplacer, indent ? 2 : undefined)}\n`, + ); + + const tryMakeUtils = async () => { + await null; + try { + // XXX pass fetch to getNetworkConfig() explicitly + // await null above makes this await safe + // eslint-disable-next-line @jessie.js/no-nested-await + const networkConfig = await getNetworkConfig(env); + return makeWalletUtils({ fetch, execFileSync, delay }, networkConfig); + } catch (err) { + throw new CommanderError(1, 'RPC_FAIL', err.message); + } }; const liquidationCmd = interCmd @@ -142,7 +241,7 @@ export const makeInterCommand = async ( liquidationCmd .command('status') .description( - `show amount liquidating, oracle price + `show amount liquidating, vault manager price For example: @@ -153,57 +252,122 @@ For example: } `, ) - .option('--manager [number]', 'Vault Manager', Number, 0) + .option('--manager ', 'Vault Manager', Number, 0) .action(async opts => { - const { agoricNames, readLatestHead } = await rpcTools(); + const { agoricNames, readLatestHead } = await tryMakeUtils(); const [metrics, quote] = await Promise.all([ readLatestHead(`published.vaultFactory.manager${opts.manager}.metrics`), readLatestHead(`published.vaultFactory.manager${opts.manager}.quotes`), ]); const info = fmtMetrics(metrics, quote, values(agoricNames.vbankAsset)); - stdout.write(JSON.stringify(info, bigintReplacer, 2)); - stdout.write('\n'); + show(info, true); }); 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()}`) + /** + * @param {string} from + * @param {import('@agoric/smart-wallet/src/offers.js').OfferSpec} offer + * @param {Awaited>} tools + */ + const placeBid = async (from, offer, tools) => { + const { networkConfig, agoricNames, pollOffer } = tools; + const io = { ...networkConfig, execFileSync, delay, stdout }; + + const { home, keyringBackend: backend } = interCmd.opts(); + const result = await sendAction( + { method: 'executeOffer', offer }, + { keyring: { home, backend }, from, verbose: false, ...io }, + ); + const { timestamp, txhash, height } = result; + console.error('bid is broadcast:'); + show({ timestamp, height, offerId: offer.id, txhash }); + const found = await pollOffer(from, offer.id, height); + // TODO: command to wait 'till bid exits? + const bid = coerceBid(found, agoricNames, console.warn); + if (!bid) { + console.warn('malformed bid', found); + return; + } + const info = fmtBid(bid, values(agoricNames.vbankAsset)); + show(info); + }; + + /** @param {string} literalOrName */ + const normalizeAddress = literalOrName => + normalizeAddressWithOptions(literalOrName, interCmd.opts(), { + execFileSync, + }); + + /** + * @typedef {{ + * give: string, + * desiredBuy: string, + * wantMinimum?: string, + * offerId: string, + * from: string, + * generateOnly?: boolean, + * }} SharedBidOpts + */ + + /** @param {ReturnType} cmd */ + const withSharedBidOptions = cmd => + cmd + .requiredOption( + '--from
', + 'wallet address literal or name', + normalizeAddress, + ) + .requiredOption('--give ', 'IST to bid') + .option( + '--desiredBuy ', + 'max Collateral wanted', + String, + '1_000_000IbcATOM', + ) + .option( + '--wantMinimum ', + 'only transact a bid that supplies this much collateral', + ) + .option('--offer-id ', 'Offer id', String, `bid-${now()}`) + .option('--generate-only', 'print wallet action only'); + + withSharedBidOptions(bidCmd.command('by-price')) + .description('Place a bid on collateral by price.') + .requiredOption('--price ', 'bid price (IST/Collateral)', Number) .action( /** - * @param {{ + * @param {SharedBidOpts & { * 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, + async ({ generateOnly, ...opts }) => { + const tools = await tryMakeUtils(); + + const parseAmount = makeParseAmount( + tools.agoricNames, + msg => new InvalidArgumentError(msg), + ); + const offer = Offers.auction.Bid(tools.agoricNames.brand, { ...opts, + parseAmount, }); - outputExecuteOfferAction(offer, stdout); - stderr.write( - 'Now use `agoric wallet send ...` to sign and broadcast the offer.\n', - ); + + if (generateOnly) { + outputActionAndHint( + { method: 'executeOffer', offer }, + { stdout, stderr }, + ); + return; + } + await placeBid(opts.from, offer, tools); }, ); + /** @param {string} v */ const parsePercent = v => { const p = Number(v); if (!(p >= -100 && p <= 100)) { @@ -212,46 +376,109 @@ For example: return p / 100; }; - bidCmd - .command('by-discount') + withSharedBidOptions(bidCmd.command('by-discount')) .description( - `Print an offer to bid on collateral based on discount from oracle price.`, + `Place a bid on collateral based on discount from oracle price.`, ) .requiredOption( - '--discount [percent]', + '--discount ', '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 {{ + * @param {SharedBidOpts & { * 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, + async ({ generateOnly, ...opts }) => { + const tools = await tryMakeUtils(); + + const parseAmount = makeParseAmount( + tools.agoricNames, + msg => new InvalidArgumentError(msg), + ); + const offer = Offers.auction.Bid(tools.agoricNames.brand, { ...opts, + parseAmount, }); - outputExecuteOfferAction(offer, stdout); - stderr.write( - 'Now use `agoric wallet send ...` to sign and broadcast the offer.\n', - ); + if (generateOnly) { + outputActionAndHint( + { method: 'executeOffer', offer }, + { stdout, stderr }, + ); + return; + } + await placeBid(opts.from, offer, tools); }, ); - const normalizeAddress = literalOrName => - normalizeAddressWithOptions(literalOrName, interCmd.opts(), { - execFileSync, - }); + bidCmd + .command('cancel') + .description('Try to exit a bid offer') + .argument('id', 'offer id (as from bid list)') + .requiredOption( + '--from
', + 'wallet address literal or name', + normalizeAddress, + ) + .option('--generate-only', 'print wallet action only') + .action( + /** + * @param {string} id + * @param {{ + * from: string, + * generateOnly?: boolean, + * }} opts + */ + async (id, { from, generateOnly }) => { + /** @type {TryExitOfferAction} */ + const action = { method: 'tryExitOffer', offerId: id }; + + if (generateOnly) { + outputActionAndHint(action, { stdout, stderr }); + return; + } + + const { networkConfig, readLatestHead } = await tryMakeUtils(); + + const current = await getCurrent(from, { readLatestHead }); + const liveIds = current.liveOffers.map(([i, _s]) => i); + if (!liveIds.includes(id)) { + throw new InvalidArgumentError( + `${id} not in live offer ids: ${liveIds}`, + ); + } + + const io = { ...networkConfig, execFileSync, delay, stdout }; + + const { home, keyringBackend: backend } = interCmd.opts(); + const result = await sendAction(action, { + keyring: { home, backend }, + from, + verbose: false, + ...io, + }); + const { timestamp, txhash, height } = result; + console.error('cancel action is broadcast:'); + show({ timestamp, height, offerId: id, txhash }); + + const checkGone = async blockInfo => { + const pollResult = await getCurrent(from, { readLatestHead }); + const found = pollResult.liveOffers.find(([i, _]) => i === id); + if (found) throw Error('retry'); + return blockInfo; + }; + const blockInfo = await pollBlocks({ + retryMessage: 'offer still live in block', + ...networkConfig, + execFileSync, + delay, + })(checkGone); + console.error('bid', id, 'is no longer live'); + show(blockInfo); + }, + ); bidCmd .command('list') @@ -261,8 +488,8 @@ For example: 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"} +{"id":"bid-1679677228803","price":"9 IST/IbcATOM","give":{"${KW.Bid}":"50IST"},"want":"5IbcATOM"} +{"id":"bid-1679677312341","discount":10,"give":{"${KW.Bid}":"200IST"},"want":"1IbcATOM"} `, ) .requiredOption( @@ -270,99 +497,59 @@ $ inter bid list --from my-acct '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()}`) + .option('--all', 'show exited bids as well') .action( /** * @param {{ - * giveCollateral: number, - * collateralBrand: string, - * offerId: string, + * from: string, + * all?: boolean, * }} opts */ - async ({ collateralBrand, ...opts }) => { - const { agoricNames } = await rpcTools(); - const offer = Offers.reserve.AddCollateral(agoricNames.brand, { - collateralBrandKey: collateralBrand, - ...opts, - }); - outputExecuteOfferAction(offer, stdout); + async opts => { + const { agoricNames, readLatestHead, storedWalletState } = + await tryMakeUtils(); + + const [current, state] = await Promise.all([ + getCurrent(opts.from, { readLatestHead }), + storedWalletState(opts.from), + ]); + const entries = opts.all + ? state.offerStatuses.entries() + : current.liveOffers; + for (const [id, spec] of entries) { + const offerStatus = state.offerStatuses.get(id) || spec; + harden(offerStatus); // coalesceWalletState should do this + // console.debug(offerStatus.invitationSpec); + if (!matches(offerStatus.invitationSpec, bidInvitationShape)) + continue; + + const bid = coerceBid(offerStatus, agoricNames, console.warn); + if (!bid) continue; + + const info = fmtBid(bid, values(agoricNames.vbankAsset)); + show(info); + } }, ); + + const assetCmd = interCmd + .command('vbank') + .description('vbank asset commands'); + assetCmd + .command('list') + .description('list registered assets with decimalPlaces, boardId, etc.') + .action(async () => { + const { agoricNames } = await tryMakeUtils(); + const assets = Object.values(agoricNames.vbankAsset).map(a => { + return { + issuerName: a.issuerName, + denom: a.denom, + brand: { boardId: a.brand.getBoardId() }, + displayInfo: { decimalPlaces: a.displayInfo.decimalPlaces }, + }; + }); + show(assets, true); + }); + return interCmd; }; diff --git a/packages/agoric-cli/src/commands/oracle.js b/packages/agoric-cli/src/commands/oracle.js index 54d5b744ec0..89cef0f4695 100644 --- a/packages/agoric-cli/src/commands/oracle.js +++ b/packages/agoric-cli/src/commands/oracle.js @@ -17,7 +17,7 @@ const scaleDecimals = num => BigInt(num * Number(COSMOS_UNIT)); * * @param {import('anylogger').Logger} logger */ -export const makeOracleCommand = async logger => { +export const makeOracleCommand = logger => { const oracle = new Command('oracle').description('Oracle commands').usage( ` WALLET=my-wallet diff --git a/packages/agoric-cli/src/commands/perf.js b/packages/agoric-cli/src/commands/perf.js index 5f6ae273ff1..b3c2f245553 100644 --- a/packages/agoric-cli/src/commands/perf.js +++ b/packages/agoric-cli/src/commands/perf.js @@ -26,7 +26,7 @@ const SLEEP_SECONDS = 0.1; * * @param {import('anylogger').Logger} logger */ -export const makePerfCommand = async logger => { +export const makePerfCommand = logger => { const perf = new Command('perf') .description('Performance testing commands') .option( @@ -98,7 +98,7 @@ export const makePerfCommand = async logger => { if (sharedOpts.home) { cmd.push(`--home=${sharedOpts.home}`); } - execSwingsetTransaction(cmd, networkConfig, opts.from); + execSwingsetTransaction(cmd, { from: opts.from, ...networkConfig }); }); return perf; diff --git a/packages/agoric-cli/src/commands/psm.js b/packages/agoric-cli/src/commands/psm.js index a5147db4a78..11a684c17cd 100644 --- a/packages/agoric-cli/src/commands/psm.js +++ b/packages/agoric-cli/src/commands/psm.js @@ -34,7 +34,7 @@ function collectValues(val, memo) { * * @param {import('anylogger').Logger} logger */ -export const makePsmCommand = async logger => { +export const makePsmCommand = logger => { const psm = new Command('psm').description('PSM commands').usage( ` WALLET=my-wallet diff --git a/packages/agoric-cli/src/commands/reserve.js b/packages/agoric-cli/src/commands/reserve.js index a3f0e200b11..560fab8d7cc 100644 --- a/packages/agoric-cli/src/commands/reserve.js +++ b/packages/agoric-cli/src/commands/reserve.js @@ -1,18 +1,48 @@ /* eslint-disable @jessie.js/no-nested-await */ // @ts-check /* eslint-disable func-names */ -/* global fetch */ +/* global fetch, process */ +import { Offers } from '@agoric/inter-protocol/src/clientSupport.js'; import { Command } from 'commander'; import { makeRpcUtils } from '../lib/rpc.js'; -import { outputExecuteOfferAction } from '../lib/wallet.js'; +import { outputActionAndHint } from '../lib/wallet.js'; /** - * * @param {import('anylogger').Logger} _logger + * @param io */ -export const makeReserveCommand = async _logger => { +export const makeReserveCommand = (_logger, io = {}) => { + const { stdout = process.stdout, stderr = process.stderr, now } = io; const reserve = new Command('reserve').description('Asset Reserve commands'); + reserve + .command('add') + .description('add collateral to the reserve') + .requiredOption('--give ', 'Collateral to give', Number) + .option('--collateral-brand ', 'Collateral brand key', 'IbcATOM') + .option('--offer-id ', 'Offer id', String, `addCollateral-${now()}`) + .action( + /** + * @param {{ + * give: number, + * collateralBrand: string, + * offerId: string, + * }} opts + */ + async ({ collateralBrand, ...opts }) => { + const { agoricNames } = await makeRpcUtils({ fetch }); + + const offer = Offers.reserve.AddCollateral(agoricNames.brand, { + collateralBrandKey: collateralBrand, + ...opts, + }); + outputActionAndHint( + { method: 'executeOffer', offer }, + { stdout, stderr }, + ); + }, + ); + reserve .command('proposeBurn') .description('propose a call to burnFeesToReduceShortfall') @@ -58,9 +88,10 @@ export const makeReserveCommand = async _logger => { proposal: {}, }; - outputExecuteOfferAction(offer); - - console.warn('Now execute the prepared offer'); + outputActionAndHint( + { method: 'executeOffer', offer }, + { stdout, stderr }, + ); }); return reserve; diff --git a/packages/agoric-cli/src/commands/vaults.js b/packages/agoric-cli/src/commands/vaults.js index 397e75e5871..0f0577ada68 100644 --- a/packages/agoric-cli/src/commands/vaults.js +++ b/packages/agoric-cli/src/commands/vaults.js @@ -16,7 +16,7 @@ import { getCurrent, outputExecuteOfferAction } from '../lib/wallet.js'; * * @param {import('anylogger').Logger} logger */ -export const makeVaultsCommand = async logger => { +export const makeVaultsCommand = logger => { const vaults = new Command('vaults') .description('Vault Factory commands') .option('--home [dir]', 'agd application home directory') diff --git a/packages/agoric-cli/src/commands/wallet.js b/packages/agoric-cli/src/commands/wallet.js index f305401e5a3..a65eec0e924 100644 --- a/packages/agoric-cli/src/commands/wallet.js +++ b/packages/agoric-cli/src/commands/wallet.js @@ -52,9 +52,10 @@ export const makeWalletCommand = async () => { const { home, keyringBackend: backend } = wallet.opts(); const tx = ['provision-one', nickname, account, 'SMART_WALLET']; if (spend) { - execSwingsetTransaction(tx, networkConfig, account, false, { - home, - backend, + execSwingsetTransaction(tx, { + from: account, + keyring: { home, backend }, + ...networkConfig, }); } else { const params = fetchSwingsetParams(networkConfig); @@ -69,9 +70,11 @@ export const makeWalletCommand = async () => { .join(' + '); process.stdout.write(`Provisioning a wallet costs ${costs}\n`); process.stdout.write(`To really provision, rerun with --spend or...\n`); - execSwingsetTransaction(tx, networkConfig, account, true, { - home, - backend, + execSwingsetTransaction(tx, { + from: account, + dryRun: true, + keyring: { home, backend }, + ...networkConfig, }); } }); @@ -91,13 +94,12 @@ export const makeWalletCommand = async () => { const { home, keyringBackend: backend } = wallet.opts(); const offerBody = fs.readFileSync(offer).toString(); - execSwingsetTransaction( - ['wallet-action', '--allow-spend', offerBody], - networkConfig, + execSwingsetTransaction(['wallet-action', '--allow-spend', offerBody], { from, dryRun, - { home, backend }, - ); + keyring: { home, backend }, + ...networkConfig, + }); }); wallet diff --git a/packages/agoric-cli/src/lib/chain.js b/packages/agoric-cli/src/lib/chain.js index 0647192e101..5b98e14c345 100644 --- a/packages/agoric-cli/src/lib/chain.js +++ b/packages/agoric-cli/src/lib/chain.js @@ -1,14 +1,19 @@ // @ts-check /* global process */ import { normalizeBech32 } from '@cosmjs/encoding'; -import { execFileSync } from 'child_process'; +import { execFileSync as execFileSyncAmbient } from 'child_process'; const agdBinary = 'agd'; +/** + * @param {string} literalOrName + * @param {{ keyringBackend?: string }} opts + * @param {{ execFileSync: typeof execFileSyncAmbient }} [io] + */ export const normalizeAddressWithOptions = ( literalOrName, { keyringBackend = undefined } = {}, - io = { execFileSync }, + io = { execFileSync: execFileSyncAmbient }, ) => { try { return normalizeBech32(literalOrName); @@ -30,22 +35,27 @@ export const normalizeAddressWithOptions = ( harden(normalizeAddressWithOptions); /** - * SECURITY: closes over process and child_process - * * @param {ReadonlyArray} swingsetArgs - * @param {import('./rpc').MinimalNetworkConfig} net - * @param {string} from - * @param {boolean} [dryRun] - * @param {{home: string, backend: string}} [keyring] + * @param {import('./rpc').MinimalNetworkConfig & { + * from: string, + * dryRun?: boolean, + * verbose?: boolean, + * keyring?: {home?: string, backend: string} + * stdout?: Pick + * execFileSync?: typeof import('child_process').execFileSync + * }} opts */ -export const execSwingsetTransaction = ( - swingsetArgs, - net, - from, - dryRun = false, - keyring = undefined, -) => { - const { chainName, rpcAddrs } = net; +export const execSwingsetTransaction = (swingsetArgs, opts) => { + const { + from, + dryRun = false, + verbose = true, + keyring = undefined, + chainName, + rpcAddrs, + stdout = process.stdout, + execFileSync = execFileSyncAmbient, + } = opts; const homeOpt = keyring?.home ? [`--home=${keyring.home}`] : []; const backendOpt = keyring?.backend ? [`--keyring-backend=${keyring.backend}`] @@ -58,21 +68,21 @@ export const execSwingsetTransaction = ( ); if (dryRun) { - process.stdout.write(`Run this interactive command in shell:\n\n`); - process.stdout.write(`${agdBinary} `); - process.stdout.write(cmd.join(' ')); - process.stdout.write('\n'); + stdout.write(`Run this interactive command in shell:\n\n`); + stdout.write(`${agdBinary} `); + stdout.write(cmd.join(' ')); + stdout.write('\n'); } else { const yesCmd = cmd.concat(['--yes']); - console.log('Executing ', yesCmd); - execFileSync(agdBinary, yesCmd); + if (verbose) console.log('Executing ', yesCmd); + return execFileSync(agdBinary, yesCmd); } }; harden(execSwingsetTransaction); // xxx rpc should be able to query this by HTTP without shelling out export const fetchSwingsetParams = net => { - const { chainName, rpcAddrs } = net; + const { chainName, rpcAddrs, execFileSync = execFileSyncAmbient } = net; const cmd = [ `--node=${rpcAddrs[0]}`, `--chain-id=${chainName}`, @@ -86,3 +96,79 @@ export const fetchSwingsetParams = net => { return JSON.parse(buffer.toString()); }; harden(fetchSwingsetParams); + +/** + * @param {import('./rpc').MinimalNetworkConfig & { + * execFileSync: typeof import('child_process').execFileSync, + * delay: (ms: number) => Promise, + * period?: number, + * retryMessage?: string, + * }} opts + * @returns {(l: (b: { time: string, height: string }) => Promise) => Promise} + */ +export const pollBlocks = opts => async lookup => { + const { execFileSync, delay, rpcAddrs, period = 3 * 1000 } = opts; + const { retryMessage } = opts; + + const nodeArgs = [`--node=${rpcAddrs[0]}`]; + + await null; // separate sync prologue + + for (;;) { + const sTxt = execFileSync(agdBinary, ['status', ...nodeArgs]); + const status = JSON.parse(sTxt.toString()); + const { + SyncInfo: { latest_block_time: time, latest_block_height: height }, + } = status; + try { + // see await null above + // eslint-disable-next-line @jessie.js/no-nested-await, no-await-in-loop + const result = await lookup({ time, height }); + return result; + } catch (_err) { + console.error( + time, + retryMessage || 'not in block', + height, + 'retrying...', + ); + // eslint-disable-next-line @jessie.js/no-nested-await, no-await-in-loop + await delay(period); + } + } +}; + +/** + * @param {string} txhash + * @param {import('./rpc').MinimalNetworkConfig & { + * execFileSync: typeof import('child_process').execFileSync, + * delay: (ms: number) => Promise, + * period?: number, + * }} opts + */ +export const pollTx = async (txhash, opts) => { + const { execFileSync, rpcAddrs, chainName } = opts; + + const nodeArgs = [`--node=${rpcAddrs[0]}`]; + const outJson = ['--output', 'json']; + + const lookup = async () => { + const out = execFileSync( + agdBinary, + [ + 'query', + 'tx', + txhash, + `--chain-id=${chainName}`, + ...nodeArgs, + ...outJson, + ], + { stdio: ['ignore', 'pipe', 'ignore'] }, + ); + // XXX this type is defined in a .proto file somewhere + /** @type {{ height: string, txhash: string, code: number, timestamp: string }} */ + const info = JSON.parse(out.toString()); + return info; + }; + return pollBlocks({ ...opts, retryMessage: 'tx not in block' })(lookup); +}; diff --git a/packages/agoric-cli/src/lib/rpc.js b/packages/agoric-cli/src/lib/rpc.js index cf9192bafeb..1ef3f6fcdbd 100644 --- a/packages/agoric-cli/src/lib/rpc.js +++ b/packages/agoric-cli/src/lib/rpc.js @@ -114,9 +114,10 @@ export const makeVStorage = (powers, config = networkConfig) => { * Read values going back as far as available * * @param {string} path + * @param {number | string} [minHeight] * @returns {Promise} */ - async readFully(path) { + async readFully(path, minHeight = undefined) { const parts = []; // undefined the first iteration, to query at the highest let blockHeight; @@ -139,7 +140,8 @@ export const makeVStorage = (powers, config = networkConfig) => { } parts.push(values); // console.debug('PUSHED', values); - // console.debug('NEW', { blockHeight }); + // console.debug('NEW', { blockHeight, minHeight }); + if (minHeight && Number(blockHeight) <= Number(minHeight)) break; } while (blockHeight > 0); return parts.flat(); }, @@ -224,6 +226,10 @@ export const makeAgoricNames = async (ctx, vstorage) => { return { ...Object.fromEntries(entries), reverse }; }; +/** + * @param {{ fetch: typeof window.fetch }} io + * @param {MinimalNetworkConfig} config + */ export const makeRpcUtils = async ({ fetch }, config = networkConfig) => { try { const vstorage = makeVStorage({ fetch }, config); diff --git a/packages/agoric-cli/src/lib/wallet.js b/packages/agoric-cli/src/lib/wallet.js index 19abe62c36b..534862af4ca 100644 --- a/packages/agoric-cli/src/lib/wallet.js +++ b/packages/agoric-cli/src/lib/wallet.js @@ -3,8 +3,13 @@ import { iterateReverse } from '@agoric/casting'; import { makeWalletStateCoalescer } from '@agoric/smart-wallet/src/utils.js'; -import { boardSlottingMarshaller } from './rpc.js'; +import { execSwingsetTransaction, pollBlocks, pollTx } from './chain.js'; +import { boardSlottingMarshaller, makeRpcUtils } from './rpc.js'; +/** @typedef {import('@agoric/smart-wallet/src/smartWallet.js').CurrentWalletRecord} CurrentWalletRecord */ +/** @typedef {import('@agoric/vats/tools/board-utils.js').AgoricNamesRemotes} AgoricNamesRemotes */ + +const { values } = Object; const marshaller = boardSlottingMarshaller(); /** @@ -17,6 +22,16 @@ export const getCurrent = (addr, { readLatestHead }) => { return readLatestHead(`published.wallet.${addr}.current`); }; +/** + * @param {string} addr + * @param {Pick} io + * @returns {Promise} + */ +export const getLastUpdate = (addr, { readLatestHead }) => { + // @ts-expect-error cast + return readLatestHead(`published.wallet.${addr}`); +}; + /** * @param {import('@agoric/smart-wallet/src/smartWallet').BridgeAction} bridgeAction * @param {Pick} [stdout] @@ -27,6 +42,21 @@ export const outputAction = (bridgeAction, stdout = process.stdout) => { stdout.write('\n'); }; +const sendHint = + 'Now use `agoric wallet send ...` to sign and broadcast the offer.\n'; + +/** + * @param {import('@agoric/smart-wallet/src/smartWallet').BridgeAction} bridgeAction + * @param {{ + * stdout: Pick, + * stderr: Pick, + * }} io + */ +export const outputActionAndHint = (bridgeAction, { stdout, stderr }) => { + outputAction(bridgeAction, stdout); + stderr.write(sendHint); +}; + /** * @param {import('@agoric/smart-wallet/src/offers.js').OfferSpec} offer * @param {Pick} [stdout] @@ -60,3 +90,178 @@ export const coalesceWalletState = async (follower, invitationBrand) => { return coalescer.state; }; + +/** + * Sign and broadcast a wallet-action. + * + * @throws { Error & { code: number } } if transaction fails + * @param {import('@agoric/smart-wallet/src/smartWallet').BridgeAction} bridgeAction + * @param {import('./rpc').MinimalNetworkConfig & { + * from: string, + * verbose?: boolean, + * keyring?: {home?: string, backend: string}, + * stdout: Pick, + * execFileSync: typeof import('child_process').execFileSync, + * delay: (ms: number) => Promise, + * }} opts + */ +export const sendAction = async (bridgeAction, opts) => { + const offerBody = JSON.stringify(marshaller.serialize(bridgeAction)); + + // tryExit should not require --allow-spend + // https://github.com/Agoric/agoric-sdk/issues/7291 + const spendMethods = ['executeOffer', 'tryExitOffer']; + const spendArg = spendMethods.includes(bridgeAction.method) + ? ['--allow-spend'] + : []; + + const act = ['wallet-action', ...spendArg, offerBody]; + const out = execSwingsetTransaction([...act, '--output', 'json'], opts); + assert(out); // not dry run + const tx = JSON.parse(out); + if (tx.code !== 0) { + const err = Error(`failed to send action. code: ${tx.code}`); + // @ts-expect-error XXX how to add properties to an error? + err.code = tx.code; + throw err; + } + + return pollTx(tx.txhash, opts); +}; + +/** + * @param {CurrentWalletRecord} current + * @param {AgoricNamesRemotes} agoricNames + */ +export const findContinuingIds = (current, agoricNames) => { + // XXX should runtime type-check + /** @type {{ offerToUsedInvitation: [string, Amount<'set'>][]}} */ + const { offerToUsedInvitation: entries } = /** @type {any} */ (current); + + assert(Array.isArray(entries)); + + const keyOf = (obj, val) => { + const found = Object.entries(obj).find(e => e[1] === val); + return found && found[0]; + }; + + const found = []; + for (const [offerId, { value }] of entries) { + /** @type {{ description: string, instance: unknown }[]} */ + const [{ description, instance }] = value; + if ( + description === 'charter member invitation' || + /Voter\d+/.test(description) + ) { + const instanceName = keyOf(agoricNames.instance, instance); + found.push({ instance, instanceName, description, offerId }); + } + } + return found; +}; + +export const makeWalletUtils = async ( + { fetch, execFileSync, delay }, + networkConfig, +) => { + const { agoricNames, fromBoard, readLatestHead, vstorage } = + await makeRpcUtils({ fetch }, networkConfig); + /** + * @param {string} from + * @param {number|string} [minHeight] + */ + const storedWalletState = async (from, minHeight = undefined) => { + const m = boardSlottingMarshaller(fromBoard.convertSlotToVal); + + const history = await vstorage.readFully( + `published.wallet.${from}`, + minHeight, + ); + + /** @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; + harden(coalesced); + return coalesced; + }; + + /** + * Get OfferStatus by id, polling until available. + * + * @param {string} from + * @param {string|number} id + * @param {number|string} minHeight + */ + const pollOffer = async (from, id, minHeight) => { + const lookup = async () => { + // eslint-disable-next-line @jessie.js/no-nested-await, no-await-in-loop + const { offerStatuses } = await storedWalletState(from, minHeight); + const offerStatus = [...offerStatuses.values()].find(s => s.id === id); + if (!offerStatus) throw Error('retry'); + harden(offerStatus); + return offerStatus; + }; + const retryMessage = 'offer not in wallet at block'; + const opts = { ...networkConfig, execFileSync, delay, retryMessage }; + return pollBlocks(opts)(lookup); + }; + + return { + networkConfig, + agoricNames, + fromBoard, + vstorage, + readLatestHead, + storedWalletState, + pollOffer, + }; +}; + +/** + * @param {{ + * brand: Record, + * vbankAsset: Record, + * }} agoricNames + * @param {(msg: string) => Error} makeError error constructor + * @returns {(a: string) => Amount<'nat'>} + */ +export const makeParseAmount = + (agoricNames, makeError = msg => new RangeError(msg)) => + opt => { + assert.typeof(opt, 'string', 'parseAmount expected string'); + const m = opt.match(/^(?[\d_]+(\.[\d_]+)?)(?[A-Z]\w*?)$/); + if (!m || !m.groups) { + throw makeError(`invalid amount: ${opt}`); + } + const anyBrand = agoricNames.brand[m.groups.brand]; + if (!anyBrand) { + throw makeError(`unknown brand: ${m.groups.brand}`); + } + const assetDesc = values(agoricNames.vbankAsset).find( + d => d.brand === anyBrand, + ); + if (!assetDesc) { + throw makeError(`unknown brand: ${m.groups.brand}`); + } + const { displayInfo } = assetDesc; + if (!displayInfo.decimalPlaces || displayInfo.assetKind !== 'nat') { + throw makeError(`bad brand: ${displayInfo}`); + } + const value = BigInt( + Number(m.groups.value.replace(/_/g, '')) * + 10 ** displayInfo.decimalPlaces, + ); + /** @type {Brand<'nat'>} */ + // @ts-expect-error dynamic cast + const natBrand = anyBrand; + const amt = { value, brand: natBrand }; + return amt; + }; diff --git a/packages/agoric-cli/test/snapshots/test-inter-cli.js.md b/packages/agoric-cli/test/snapshots/test-inter-cli.js.md index bf1f70b68f7..1d8180a9c83 100644 --- a/packages/agoric-cli/test/snapshots/test-inter-cli.js.md +++ b/packages/agoric-cli/test/snapshots/test-inter-cli.js.md @@ -10,10 +10,10 @@ Generated by [AVA](https://avajs.dev). `Usage: agops inter [options] [command]␊ ␊ - Inter Protocol tool␊ + Inter Protocol commands for liquidation bidding etc.␊ ␊ Options:␊ - --home [dir] agd CosmosSDK application home directory␊ + --home agd CosmosSDK application home directory␊ --keyring-backend [os|file|test] keyring's backend (os|file|test) (default␊ "os")␊ -h, --help display help for command␊ @@ -21,7 +21,7 @@ Generated by [AVA](https://avajs.dev). Commands:␊ liquidation liquidation commands␊ bid auction bidding commands␊ - reserve reserve commands␊ + vbank vbank asset commands␊ help [command] display help for command` ## Usage: inter liquidation status @@ -30,7 +30,7 @@ Generated by [AVA](https://avajs.dev). `Usage: agops inter liquidation status [options]␊ ␊ - show amount liquidating, oracle price␊ + show amount liquidating, vault manager price␊ ␊ For example:␊ ␊ @@ -42,7 +42,7 @@ Generated by [AVA](https://avajs.dev). ␊ ␊ Options:␊ - --manager [number] Vault Manager (default: 0)␊ + --manager Vault Manager (default: 0)␊ -h, --help display help for command` ## Usage: inter bid by-price @@ -51,15 +51,18 @@ Generated by [AVA](https://avajs.dev). `Usage: agops inter bid by-price [options]␊ ␊ - Print an offer to bid collateral by price.␊ + Place a bid on 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` + --from
wallet address literal or name␊ + --give IST to bid␊ + --desiredBuy max Collateral wanted (default: "1_000_000IbcATOM")␊ + --wantMinimum only transact a bid that supplies this much␊ + collateral␊ + --offer-id Offer id (default: "bid-978307200000")␊ + --generate-only print wallet action only␊ + --price bid price (IST/Collateral)␊ + -h, --help display help for command` ## Usage: inter bid by-discount @@ -67,15 +70,18 @@ Generated by [AVA](https://avajs.dev). `Usage: agops inter bid by-discount [options]␊ ␊ - Print an offer to bid on collateral based on discount from oracle price.␊ + Place a 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` + --from
wallet address literal or name␊ + --give IST to bid␊ + --desiredBuy max Collateral wanted (default: "1_000_000IbcATOM")␊ + --wantMinimum only transact a bid that supplies this much␊ + collateral␊ + --offer-id Offer id (default: "bid-978307200000")␊ + --generate-only print wallet action only␊ + --discount bid discount (0 to 100) or markup (0 to -100) %␊ + -h, --help display help for command` ## Usage: inter bid list @@ -94,18 +100,32 @@ Generated by [AVA](https://avajs.dev). ␊ Options:␊ --from
wallet address literal or name␊ + --all show exited bids as well␊ -h, --help display help for command` -## Usage: inter reserve add +## Usage: inter bid cancel > Command usage: - `Usage: agops inter reserve add [options]␊ + `Usage: agops inter bid cancel [options] ␊ ␊ - add collateral to the reserve␊ + Try to exit a bid offer␊ + ␊ + Arguments:␊ + id offer id (as from bid list)␊ + ␊ + Options:␊ + --from
wallet address literal or name␊ + --generate-only print wallet action only␊ + -h, --help display help for command` + +## Usage: inter vbank list + +> Command usage: + + `Usage: agops inter vbank list [options]␊ + ␊ + list registered assets with decimalPlaces, boardId, etc.␊ ␊ 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` + -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 index b1ccc8f72bb..8466c1334a0 100644 Binary files a/packages/agoric-cli/test/snapshots/test-inter-cli.js.snap 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 index a141913f8bb..8ae0822dad5 100644 --- a/packages/agoric-cli/test/test-inter-cli.js +++ b/packages/agoric-cli/test/test-inter-cli.js @@ -7,15 +7,12 @@ 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'; +import { fmtBid, makeInterCommand, KW } from '../src/commands/inter.js'; +import { makeParseAmount } from '../src/lib/wallet.js'; const { entries } = Object; -const unused = (...args) => { - console.error('unused?', ...args); - assert.fail('should not be needed'); -}; - +/** @typedef {import('commander').Command} Command */ /** @typedef {import('@agoric/vats/tools/board-utils.js').BoardRemote} BoardRemote */ /** @@ -47,7 +44,7 @@ const agoricNames = harden({ displayInfo: { assetKind: 'nat', decimalPlaces: 6 }, issuer: /** @type {any} */ ({}), issuerName: 'IST', - proposedName: 'Agoric stable local currency', + proposedName: 'Agoric stable token', }, 'ibc/toyatom': { @@ -82,22 +79,19 @@ const offerSpec1 = harden({ numerator: mk(bslot.IST, 9n), denominator: mk(bslot.ATOM, 1n), }, - want: mk(bslot.ATOM, 5000000n), + want: mk(bslot.ATOM, 1_000_000_000_000n), }, proposal: { - exit: { onDemand: null }, - give: { - Currency: mk(bslot.IST, 50000000n), - }, + give: { [KW.Bid]: mk(bslot.IST, 50_000_000n) }, }, }, }); const publishedNames = { agoricNames: { - brand: entries(agoricNames.brand), - instance: entries(agoricNames.instance), - vbankAsset: entries(agoricNames.vbankAsset), + brand: { _: entries(agoricNames.brand) }, + instance: { _: entries(agoricNames.instance) }, + vbankAsset: { _: entries(agoricNames.vbankAsset) }, }, }; @@ -125,13 +119,14 @@ const makeNet = published => { ); if (!matched) throw Error(`fetch what?? ${url}`); const { path } = matched.groups; - let data = published; + let node = published; for (const key of path.split('.')) { - data = data[key]; - if (!data) throw Error(`query what?? ${path}`); + node = node[key]; + if (!node) throw Error(`query what?? ${path}`); } + if (!node._) throw Error(`no data at ${path}`); return harden({ - json: async () => fmt(data), + json: async () => fmt(node._), }); }; @@ -149,13 +144,40 @@ const makeProcess = (t, keyring, out) => { 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}`); + // first arg that doesn't sart with -- + const cmd = args.find(a => !a.startsWith('--')); + t.truthy(cmd); + switch (cmd) { + case 'keys': { + ['--node', '--chain'].forEach(opt => { + const ix = args.findIndex(a => a.startsWith(opt)); + if (ix >= 0) { + args.splice(ix, 1); + } + }); + 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; + } + case 'status': { + return JSON.stringify({ + SyncInfo: { latest_block_time: 123, latest_block_height: 456 }, + }); + } + case 'query': { + return JSON.stringify({}); + } + case 'tx': { + return JSON.stringify({ code: 0 }); + } + default: + t.fail(`agd cmd not impl:${args[0]}`); } - return addr; + break; } default: throw Error('not impl'); @@ -168,26 +190,95 @@ const makeProcess = (t, keyring, out) => { return true; }, }); + + /** @type {typeof setTimeout} */ + // @ts-expect-error mock + const setTimeout = (f, _ms) => Promise.resolve().then(_ => f()); + return { env: {}, stdout, stderr: { write: _s => true }, now: () => Date.parse('2001-01-01'), + setTimeout, createCommand, execFileSync, }; }; -test('inter bid place by-price: output is correct', async t => { +/** + * @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: { + [KW.Bid]: { brand: topBrands.ATOM, value: 20000000n }, + }, + }, + payouts: { + Collateral: { brand: topBrands.ATOM, value: 5_000_000n }, + [KW.Bid]: { brand: topBrands.IST, value: 37_000_000n }, + }, +}); + +const govWallets = { + [govKeyring.gov1]: { + _: { updated: 'offerStatus', status: offerStatus2 }, + current: { _: { liveOffers: [[offerStatus2.id, offerStatus2]] } }, + }, + [govKeyring.gov2]: { current: {} }, +}; + +test('amount parsing', t => { + const parseAmount = makeParseAmount(agoricNames); + const b = topBrands; + + t.deepEqual(parseAmount('1ATOM'), { brand: b.ATOM, value: 1_000_000n }); + t.deepEqual( + parseAmount('10_000ATOM'), + { + brand: b.ATOM, + value: 10_000_000_000n, + }, + 'handle underscores', + ); + t.deepEqual( + parseAmount('1.5ATOM'), + { brand: b.ATOM, value: 1_500_000n }, + 'handle decimal', + ); + + t.throws(() => parseAmount('5'), { message: 'invalid amount: 5' }); + t.throws(() => parseAmount('50'), { message: 'invalid amount: 50' }); + t.throws(() => parseAmount('5.5.5ATOM'), { + message: 'invalid amount: 5.5.5ATOM', + }); +}); + +test('inter bid place by-price: printed offer is correct', async t => { const argv = - 'node inter bid by-price --giveCurrency 50 --price 9 --wantCollateral 5' + 'node inter bid by-price --give 50IST --price 9 --from gov1 --generate-only' .trim() .split(' '); const out = []; const cmd = await makeInterCommand( - { ...makeProcess(t, govKeyring, out), execFileSync: unused }, - makeNet(publishedNames), + { ...makeProcess(t, govKeyring, out) }, + makeNet({ ...publishedNames, wallet: govWallets }), ); cmd.exitOverride(() => t.fail('exited')); @@ -204,6 +295,8 @@ test('inter bid place by-price: output is correct', async t => { ); }); +test.todo('want as max collateral wanted'); + /** * @type {import('@agoric/smart-wallet/src/offers.js').OfferStatus & * { offerArgs: import('@agoric/inter-protocol/src/auction/auctionBook.js').BidSpec}} @@ -225,53 +318,19 @@ const offerStatus1 = harden({ }, 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 }, + [KW.Bid]: { 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 }), + makeNet({ ...publishedNames, wallet: govWallets }), ); cmd.exitOverride(() => t.fail('exited')); @@ -281,15 +340,47 @@ test('inter bid list: finds one bid', async t => { JSON.stringify({ id: 'bid-234234', discount: 10, - give: { Currency: '20 ATOM' }, + give: { [KW.Bid]: '20 ATOM' }, want: '2 ATOM', - payouts: { Collateral: '5 ATOM', Currency: '37 IST' }, + payouts: { Collateral: '5 ATOM', [KW.Bid]: '37 IST' }, }), ); }); +/** @type {(c: Command) => Command[]} */ const subCommands = c => [c, ...c.commands.flatMap(subCommands)]; +test('diagnostic for agd ENOENT', async t => { + const argv = 'node inter bid list --from gov1'.split(' '); + + const out = []; + const diag = []; + const proc = makeProcess(t, govKeyring, out); + const cmd = await makeInterCommand( + { + ...proc, + execFileSync: file => { + t.is(file, 'agd'); + throw Error('ENOENT'); + }, + }, + makeNet({}), + ); + subCommands(cmd).forEach(c => { + c.exitOverride(); + c.configureOutput({ writeErr: s => diag.push(s) }); + }); + + await t.throwsAsync(cmd.parseAsync(argv), { instanceOf: CommanderError }); + t.deepEqual( + diag.join('').trim(), + "error: option '--from
' argument 'gov1' is invalid. ENOENT: is agd in your $PATH?", + ); + t.deepEqual(out.join('').trim(), ''); +}); + +test.todo('agd ENOENT clue outside normalizeAddress'); + const usageTest = (words, blurb = 'Command usage:') => { test(`Usage: ${words}`, async t => { const argv = `node agops ${words} --help`.split(' '); @@ -320,7 +411,8 @@ usageTest('inter liquidation status'); usageTest('inter bid by-price'); usageTest('inter bid by-discount'); usageTest('inter bid list'); -usageTest('inter reserve add'); +usageTest('inter bid cancel'); +usageTest('inter vbank list'); test('formatBid', t => { const { values } = Object; @@ -329,7 +421,7 @@ test('formatBid', t => { t.deepEqual(actual, { id: 1678990150266, error: 'Error: "nameKey" not found: (a string)', - give: { Currency: '20 ATOM' }, + give: { [KW.Bid]: '20 ATOM' }, price: '10 IST/ATOM', want: '2 ATOM', }); @@ -338,10 +430,42 @@ test('formatBid', t => { const actual = fmtBid(offerStatus2, values(agoricNames.vbankAsset)); t.deepEqual(actual, { id: 'bid-234234', - give: { Currency: '20 ATOM' }, - payouts: { Collateral: '5 ATOM', Currency: '37 IST' }, + give: { [KW.Bid]: '20 ATOM' }, + payouts: { Collateral: '5 ATOM', [KW.Bid]: '37 IST' }, want: '2 ATOM', discount: 10, }); } }); + +test.todo('fmtBid with error does not show result'); +/* +_not_ like this: + +{"id":"bid-1680211654832","price":"0.7999999999999999 IST/IbcATOM","give":{"Currency":"10IST"},"want":"3IbcATOM","result":[{"reason":{"@qclass":"error","errorId":"error:anon-marshal#10001","message":"cannot grab 10000000uist coins: 4890000uist is smaller than 10000000uist: insufficient funds [agoric-labs/cosmos-sdk@v0.45.11-alpha.agoric.1.0.20230320225042-2109765fd835/x/bank/keeper/send.go:186]","name":"Error"},"status":"rejected"}],"error":"Error: cannot grab 10000000uist coins: 4890000uist is smaller than 10000000uist: insufficient funds [agoric-labs/cosmos-sdk@v0.45.11-alpha.agoric.1.0.20230320225042-2109765fd835/x/bank/keeper/send.go:186]"} +*/ + +test.todo('execSwingsetTransaction returns non-0 code'); + +test.todo('inter bid by-price shows tx, wallet status'); +/* +$ agops inter bid by-price --price 0.81 --give 0.5 --want 3 --from gov2 +2023-03-30T21:48:14.479332418Z not in block 49618 retrying... +bid is broadcast: +{"timestamp":"2023-03-30T21:48:19Z","height":"49619","offerId":"bid-1680212903989","txhash":"472A47AAE24F27E747E3E64F4644860D2A5D3AD7EC5388C4C849805034E20D38"} +first bid update: +{"id":"bid-1680212903989","price":"0.81 IST/IbcATOM","give":{"Currency":"0.5IST"},"want":"3IbcATOM","result":"Your bid has been accepted"} +*/ + +test.todo('inter bid cancel shows resulting payouts'); +/* + +*/ + +test.todo('already cancelled bid'); +/* +$ agops inter bid cancel --from gov2 bid-1680211556497 +bid-1680211556497 not in live offer ids: bid-1680211593489,bid-1680212903989,bid-1680213097499,bid-1680220217218,bid-1680220368714,bid-1680220406939 +*/ + +test.todo('--give without number'); diff --git a/packages/inter-protocol/package.json b/packages/inter-protocol/package.json index 62608ac4ded..a94c1b05b15 100644 --- a/packages/inter-protocol/package.json +++ b/packages/inter-protocol/package.json @@ -43,7 +43,8 @@ "@endo/eventual-send": "^0.16.8", "@endo/far": "^0.2.14", "@endo/marshal": "^0.8.1", - "@endo/nat": "^4.1.23" + "@endo/nat": "^4.1.23", + "agoric": "^0.18.2" }, "devDependencies": { "@agoric/deploy-script-support": "^0.9.4", diff --git a/packages/inter-protocol/src/clientSupport.js b/packages/inter-protocol/src/clientSupport.js index f4492b5fcc2..b93cf1eeb8d 100644 --- a/packages/inter-protocol/src/clientSupport.js +++ b/packages/inter-protocol/src/clientSupport.js @@ -2,6 +2,7 @@ import { Fail } from '@agoric/assert'; import { AmountMath } from '@agoric/ertp'; +import { assertAllDefined } from '@agoric/internal'; import { parseRatio } from '@agoric/zoe/src/contractSupport/ratio.js'; // XXX support other decimal places @@ -204,12 +205,13 @@ const makePsmSwapOffer = (instance, brands, opts) => { }; /** - * @param {Record} brands + * @param {Record} _brands * @param {{ * offerId: string, - * collateralBrandKey: string, - * giveCurrency: number, - * wantCollateral: number, + * give: string, + * desiredBuy: string, + * wantMinimum?: string, + * parseAmount: (x: string) => Amount<'nat'>, * } & ({ * price: number, * } | { @@ -217,48 +219,58 @@ const makePsmSwapOffer = (instance, brands, opts) => { * })} opts * @returns {import('@agoric/smart-wallet/src/offers.js').OfferSpec} */ -const makeBidOffer = (brands, opts) => { - const give = { - Currency: AmountMath.make(brands.IST, scaleDecimals(opts.giveCurrency)), +const makeBidOffer = (_brands, opts) => { + assert.typeof(opts.parseAmount, 'function'); + assertAllDefined({ + offerId: opts.offerId, + give: opts.give, + desiredBuy: opts.desiredBuy, + }); + const { parseAmount } = opts; + const proposal = { + give: { Currency: parseAmount(opts.give) }, + ...(opts.wantMinimum + ? { want: { Collateral: parseAmount(opts.wantMinimum) } } + : {}), }; - /** @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 istBrand = proposal.give.Currency.brand; + const desiredBuy = parseAmount(opts.desiredBuy); const bounds = (x, lo, hi) => { assert(x >= lo && x <= hi); return x; }; + assert( + 'price' in opts || 'discount' in opts, + 'must specify price or discount', + ); /** @type {import('./auction/auctionBook.js').BidSpec} */ const offerArgs = 'price' in opts ? { - want, - offerPrice: parseRatio(opts.price, brands.IST, collateralBrand), + // TODO: update when contract uses "desiredBuy" + want: parseAmount(opts.desiredBuy), + offerPrice: parseRatio(opts.price, istBrand, desiredBuy.brand), } : { - want, + want: desiredBuy, offerBidScaling: parseRatio( (1 - bounds(opts.discount, -1, 1)).toFixed(2), - brands.IST, - brands.IST, + istBrand, + istBrand, ), }; + /** @type {import('@agoric/smart-wallet/src/offers.js').OfferSpec} */ const offerSpec = { id: opts.offerId, invitationSpec: { source: 'agoricContract', instancePath: ['auctioneer'], - callPipe: [['makeBidInvitation', [collateralBrand]]], + callPipe: [['makeBidInvitation', [desiredBuy.brand]]], }, - proposal: { give, exit: { onDemand: null } }, + proposal, offerArgs, }; return offerSpec; @@ -268,7 +280,7 @@ const makeBidOffer = (brands, opts) => { * @param {Record} brands * @param {{ * offerId: string, - * giveCollateral: number, + * give: number, * collateralBrandKey: string, * }} opts * @returns {import('@agoric/smart-wallet/src/offers.js').OfferSpec} @@ -278,7 +290,7 @@ const makeAddCollateralOffer = (brands, opts) => { const give = { Collateral: AmountMath.make( brands[opts.collateralBrandKey], - scaleDecimals(opts.giveCollateral), + scaleDecimals(opts.give), ), }; diff --git a/packages/inter-protocol/test/test-clientSupport.js b/packages/inter-protocol/test/test-clientSupport.js index 52783bc70c7..07eaf8b8a6d 100644 --- a/packages/inter-protocol/test/test-clientSupport.js +++ b/packages/inter-protocol/test/test-clientSupport.js @@ -2,6 +2,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 { makeParseAmount } from 'agoric/src/lib/wallet.js'; import { withAmountUtils } from './supports.js'; import { Offers } from '../src/clientSupport.js'; @@ -13,6 +14,28 @@ const brands = { ATOM: atom.brand, }; +const agoricNames = /** @type {const} */ ({ + brand: brands, + vbankAsset: { + uist: { + denom: 'uist', + brand: ist.brand, + displayInfo: { assetKind: 'nat', decimalPlaces: 6 }, + issuer: /** @type {any} */ ({}), + issuerName: 'IST', + proposedName: 'Agoric stable token', + }, + 'ibc/toyatom': { + denom: 'ibc/toyatom', + brand: atom.brand, + displayInfo: { assetKind: 'nat', decimalPlaces: 6 }, + issuer: /** @type {any} */ ({}), + issuerName: 'ATOM', + proposedName: 'ATOM', + }, + }, +}); + test('Offers.auction.Bid', async t => { const discounts = [ { cliArg: 0.05, offerBidScaling: makeRatio(95n, ist.brand, 100n) }, @@ -21,15 +44,16 @@ test('Offers.auction.Bid', async t => { { cliArg: -0.1, offerBidScaling: makeRatio(110n, ist.brand, 100n) }, ]; + const parseAmount = makeParseAmount(agoricNames); 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', + give: '4.56IST', discount: cliArg, + desiredBuy: '10_000ATOM', + parseAmount, }), { id: 'foo1', @@ -39,12 +63,11 @@ test('Offers.auction.Bid', async t => { callPipe: [['makeBidInvitation', [atom.brand]]], }, proposal: { - exit: { onDemand: null }, give: { Currency: ist.make(4_560_000n) }, }, offerArgs: { offerBidScaling, - want: atom.make(1_230_000n), + want: { brand: atom.brand, value: 10_000_000_000n }, }, }, ); @@ -52,14 +75,37 @@ test('Offers.auction.Bid', async t => { const price = 7; const offerPrice = makeRatio(7n, ist.brand, 1n, atom.brand); - t.log({ price, offerPrice }); t.deepEqual( Offers.auction.Bid(brands, { offerId: 'by-price2', - wantCollateral: 1.23, - giveCurrency: 4.56, - collateralBrandKey: 'ATOM', + give: '4.56IST', + price, + desiredBuy: '10_000ATOM', + parseAmount, + }), + { + id: 'by-price2', + invitationSpec: { + source: 'agoricContract', + instancePath: ['auctioneer'], + callPipe: [['makeBidInvitation', [atom.brand]]], + }, + proposal: { give: { Currency: ist.make(4_560_000n) } }, + offerArgs: { + offerPrice, + want: { brand: atom.brand, value: 10_000_000_000n }, + }, + }, + ); + + t.deepEqual( + Offers.auction.Bid(brands, { + offerId: 'by-price2', + desiredBuy: '10_000ATOM', + wantMinimum: '1.23ATOM', + give: '4.56IST', price, + parseAmount, }), { id: 'by-price2', @@ -69,13 +115,27 @@ test('Offers.auction.Bid', async t => { callPipe: [['makeBidInvitation', [atom.brand]]], }, proposal: { - exit: { onDemand: null }, give: { Currency: ist.make(4_560_000n) }, + want: { Collateral: atom.make(1_230_000n) }, }, offerArgs: { offerPrice, - want: atom.make(1_230_000n), + want: atom.make(10_000_000_000n), }, }, + 'optional want', + ); + + t.throws( + () => + // @ts-expect-error error checking test + Offers.auction.Bid(brands, { + offerId: 'by-price2', + wantMinimum: '1.23ATOM', + give: '4.56IST', + price, + parseAmount, + }), + { message: 'missing ["desiredBuy"]' }, ); }); diff --git a/packages/vats/test/bootstrapTests/test-vaults-integration.js b/packages/vats/test/bootstrapTests/test-vaults-integration.js index d6dedd5d6c3..dc349d20ec6 100644 --- a/packages/vats/test/bootstrapTests/test-vaults-integration.js +++ b/packages/vats/test/bootstrapTests/test-vaults-integration.js @@ -252,7 +252,7 @@ test('open vault with insufficient funds gives helpful error', async t => { }); test('exit bid', async t => { - const { walletFactoryDriver } = t.context; + const { walletFactoryDriver, agoricNamesRemotes } = t.context; const wd = await walletFactoryDriver.provideSmartWallet('agoric1bid'); @@ -264,12 +264,23 @@ test('exit bid', async t => { giveCollateral: 9.0, }); + const parseAmount = opt => { + const m = opt.match(/^(?[\d_.]+)(?\w+?)$/); + assert(m); + return { + value: BigInt(Number(m.groups.value.replace(/_/g, '')) * 1_000_000), + /** @type {Brand<'nat'>} */ + // @ts-expect-error mock + brand: agoricNamesRemotes.brand[m.groups.brand], + }; + }; + wd.sendOfferMaker(Offers.auction.Bid, { offerId: 'bid', - wantCollateral: 1.23, - giveCurrency: 0.1, - collateralBrandKey: 'IbcATOM', + desiredBuy: '1.23IbcATOM', + give: '0.1IST', price: 5, + parseAmount, }); await wd.tryExitOffer('bid');