diff --git a/packages/agoric-cli/src/commands/psm.js b/packages/agoric-cli/src/commands/psm.js index 0ff7d4ec729..1a608b8f3a4 100644 --- a/packages/agoric-cli/src/commands/psm.js +++ b/packages/agoric-cli/src/commands/psm.js @@ -28,6 +28,11 @@ import { const last = xs => xs[xs.length - 1]; +function collectValues(val, memo) { + memo.push(val); + return memo; +} + const { vstorage, fromBoard, agoricNames } = await makeRpcUtils({ fetch }); /** @@ -81,7 +86,7 @@ export const makePsmCommand = async logger => { const marshaller = boardSlottingMarshaller(); - const lookupInstance = ([minted, anchor]) => { + const lookupPsmInstance = ([minted, anchor]) => { const name = `psm-${minted}-${anchor}`; const instance = agoricNames.instance[name]; if (!instance) { @@ -113,6 +118,7 @@ export const makePsmCommand = async logger => { psm .command('info') .description('show governance info about the PSM (BROKEN)') + // TODO DRY with https://github.com/Agoric/agoric-sdk/issues/6181 .requiredOption( '--pair [Minted.Anchor]', 'token pair (Minted.Anchor)', @@ -155,24 +161,59 @@ export const makePsmCommand = async logger => { .action(async function () { const opts = this.opts(); console.warn('running with options', opts); - const instance = await lookupInstance(opts.pair); + const instance = await lookupPsmInstance(opts.pair); + // @ts-expect-error RpcRemote types not real instances const spendAction = makePSMSpendAction(instance, agoricNames.brand, opts); outputAction(spendAction); }); psm - .command('vote') - .description('prepare an offer to vote') - .option('--offerId [number]', 'Offer id', String(Date.now())) - .action(function () { + .command('committee') + .description('join the economic committee') + .option('--offerId [number]', 'Offer id', Number, Date.now()) + .action(async function () { + const opts = this.opts(); + + const { economicCommittee } = agoricNames.instance; + assert(economicCommittee, 'missing economicCommittee'); + + /** @type {import('../lib/psm.js').OfferSpec} */ + const offer = { + id: Number(opts.offerId), + invitationSpec: { + source: 'purse', + // @ts-expect-error rpc + instance: economicCommittee, + description: 'Voter0', // XXX it may not always be + }, + proposal: {}, + }; + + outputAction({ + method: 'executeOffer', + offer, + }); + + console.warn('Now execute the prepared offer'); + }); + + psm + .command('charter') + .description('prepare an offer to accept the charter invitation') + .option('--offerId [number]', 'Offer id', Number, Date.now()) + .action(async function () { const opts = this.opts(); + const { psmCharter } = agoricNames.instance; + assert(psmCharter, 'missing psmCharter'); + /** @type {import('../lib/psm.js').OfferSpec} */ const offer = { - id: opts.offerId, + id: Number(opts.offerId), invitationSpec: { source: 'purse', - instance: opts.instance, + // @ts-expect-error rpc + instance: psmCharter, description: 'PSM charter member invitation', }, proposal: {}, @@ -182,6 +223,131 @@ export const makePsmCommand = async logger => { method: 'executeOffer', offer, }); + + console.warn('Now execute the prepared offer'); + }); + + psm + .command('proposePauseOffers') + .description('propose a vote') + .option('--offerId [number]', 'Offer id', Number, Date.now()) + .requiredOption( + '--pair [Minted.Anchor]', + 'token pair (Minted.Anchor)', + s => s.split('.'), + ['IST', 'AUSD'], + ) + .requiredOption( + '--previousOfferId [number]', + 'offer that had continuing invitation result', + Number, + ) + .requiredOption( + '--substring [string]', + 'an offer string to pause (can be repeated)', + collectValues, + [], + ) + .option( + '--deadline [minutes]', + 'minutes from now to close the vote', + Number, + 1, + ) + .action(async function () { + const opts = this.opts(); + + const psmInstance = lookupPsmInstance(opts.pair); + + /** @type {import('../lib/psm.js').OfferSpec} */ + const offer = { + id: Number(opts.offerId), + invitationSpec: { + source: 'continuing', + previousOffer: opts.previousOfferId, + invitationMakerName: 'VoteOnPauseOffers', + // ( instance, strings list, timer deadline seconds ) + invitationArgs: harden([ + psmInstance, + opts.substring, + BigInt(opts.deadline * 60 + Math.round(Date.now() / 1000)), + ]), + }, + proposal: {}, + }; + + outputAction({ + method: 'executeOffer', + offer, + }); + + console.warn('Now execute the prepared offer'); + }); + + psm + .command('vote') + .description('vote on a question (hard-coded for now))') + .option('--offerId [number]', 'Offer id', Number, Date.now()) + .requiredOption( + '--previousOfferId [number]', + 'offer that had continuing invitation result', + Number, + ) + .requiredOption( + '--pair [Minted.Anchor]', + 'token pair (Minted.Anchor)', + s => s.split('.'), + ['IST', 'AUSD'], + ) + .requiredOption( + '--forPosition [number]', + 'index of one position to vote for (within the question description.positions); ', + Number, + ) + .action(async function () { + const opts = this.opts(); + + const questionHandleCapDataStr = await vstorage.read( + 'published.committees.Initial_Economic_Committee.latestQuestion', + ); + const questionDescriptions = storageHelper.unserialize( + questionHandleCapDataStr, + fromBoard, + ); + + 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('../lib/psm.js').OfferSpec} */ + const offer = { + id: Number(opts.offerId), + invitationSpec: { + source: 'continuing', + previousOffer: opts.previousOfferId, + invitationMakerName: 'makeVoteInvitation', + // (positionList, questionHandle) + invitationArgs: harden([ + chosenPositions, + questionDesc.questionHandle, + ]), + }, + proposal: {}, + }; + + outputAction({ + method: 'executeOffer', + offer, + }); + + console.warn('Now execute the prepared offer'); }); return psm; diff --git a/packages/agoric-cli/src/commands/wallet.js b/packages/agoric-cli/src/commands/wallet.js index 4b113e20ccf..e2494503992 100644 --- a/packages/agoric-cli/src/commands/wallet.js +++ b/packages/agoric-cli/src/commands/wallet.js @@ -1,6 +1,7 @@ // @ts-check /* eslint-disable func-names */ /* global fetch, process */ +import { execSync } from 'child_process'; import { iterateLatest, makeCastingSpec, @@ -30,15 +31,22 @@ export const makeWalletCommand = async () => { normalizeAddress, ) .requiredOption('--offer [filename]', 'path to file with prepared offer') + .option('--dry-run', 'spit out the command instead of running it') .action(function () { - const { from, offer } = this.opts(); + const { dryRun, from, offer } = this.opts(); const { chainName, rpcAddrs } = networkConfig; const cmd = `agd --node=${rpcAddrs[0]} --chain-id=${chainName} --from=${from} tx swingset wallet-action --allow-spend "$(cat ${offer})"`; - process.stdout.write('Run this interactive command in shell:\n\n'); - process.stdout.write(cmd); - process.stdout.write('\n'); + if (dryRun) { + process.stdout.write('Run this interactive command in shell:\n\n'); + process.stdout.write(cmd); + process.stdout.write('\n'); + } else { + const yesCmd = `${cmd} --yes`; + console.log('Executing ', yesCmd); + execSync(yesCmd); + } }); wallet diff --git a/packages/agoric-cli/src/lib/format.js b/packages/agoric-cli/src/lib/format.js index 75560eb046b..4265203fce6 100644 --- a/packages/agoric-cli/src/lib/format.js +++ b/packages/agoric-cli/src/lib/format.js @@ -26,7 +26,7 @@ const exampleAsset = { issuer: { boardId: null, iface: undefined }, petname: 'Agoric staking token', }; -/** @typedef {import('@agoric/smart-wallet/src/smartWallet').BrandDescriptor & {brand: {boardId: string, iface: string}}} AssetDescriptor */ +/** @typedef {import('@agoric/smart-wallet/src/smartWallet').BrandDescriptor & {brand: import('./rpc').RpcRemote}} AssetDescriptor */ /** @param {AssetDescriptor[]} assets */ export const makeAmountFormatter = assets => amt => { @@ -40,11 +40,16 @@ export const makeAmountFormatter = assets => amt => { petname, displayInfo: { assetKind, decimalPlaces = 0 }, } = asset; - const petnameStr = Array.isArray(petname) ? petname.join('.') : petname; - if (assetKind !== 'nat') return [['?'], petnameStr]; - /** @type {[qty: number, petname: string]} */ - const scaled = [Number(value) / 10 ** decimalPlaces, petnameStr]; - return scaled; + const name = Array.isArray(petname) ? petname.join('.') : petname; + switch (assetKind) { + case 'nat': + /** @type {[petname: string, qty: number]} */ + return [name, Number(value) / 10 ** decimalPlaces]; + case 'set': + return [name, value]; + default: + return [name, ['?']]; + } }; export const asPercent = ratio => { diff --git a/packages/agoric-cli/src/lib/psm.js b/packages/agoric-cli/src/lib/psm.js index e06ace720ff..7a90febd736 100644 --- a/packages/agoric-cli/src/lib/psm.js +++ b/packages/agoric-cli/src/lib/psm.js @@ -32,6 +32,7 @@ export const simpleOffers = (state, agoricNames) => { payouts, } = o; const entry = Object.entries(agoricNames.instance).find( + // @ts-expect-error xxx RpcRemote ([_name, candidate]) => candidate === instance, ); const instanceName = entry ? entry[0] : '???'; diff --git a/packages/agoric-cli/src/lib/rpc.js b/packages/agoric-cli/src/lib/rpc.js index f335894c7d4..a095713a260 100644 --- a/packages/agoric-cli/src/lib/rpc.js +++ b/packages/agoric-cli/src/lib/rpc.js @@ -1,12 +1,11 @@ -/* eslint-disable @jessie.js/no-nested-await */ // @ts-check +/* eslint-disable @jessie.js/no-nested-await */ /* global Buffer, fetch, process */ import { NonNullish } from '@agoric/assert'; /** - * @template K, V - * @typedef {[key: K, val: V]} Entry + * @typedef {{boardId: string, iface: string}} RpcRemote */ export const networkConfigUrl = agoricNetSubdomain => @@ -225,21 +224,15 @@ harden(storageHelper); /** * @param {IdMap} ctx * @param {VStorage} vstorage - * @param {string[]} [kinds] + * @returns {Promise<{brand: Record, instance: Record}>} */ -export const makeAgoricNames = async ( - ctx, - vstorage, - kinds = ['brand', 'instance'], -) => { +export const makeAgoricNames = async (ctx, vstorage) => { const entries = await Promise.all( - kinds.map(async kind => { + ['brand', 'instance'].map(async kind => { const content = await vstorage.read(`published.agoricNames.${kind}`); const parts = storageHelper.unserialize(content, ctx).at(-1); - /** @type {Entry>} */ - const entry = [kind, Object.fromEntries(parts)]; - return entry; + return [kind, Object.fromEntries(parts)]; }), ); return Object.fromEntries(entries); diff --git a/packages/agoric-cli/test/agops-governance-smoketest.sh b/packages/agoric-cli/test/agops-governance-smoketest.sh new file mode 100644 index 00000000000..9cb677bb708 --- /dev/null +++ b/packages/agoric-cli/test/agops-governance-smoketest.sh @@ -0,0 +1,88 @@ +#!/bin/sh + +if [ -z "$AGORIC_NET" ]; then + echo "AGORIC_NET env not set" + echo + echo "e.g. AGORIC_NET=ollinet (or export to save typing it each time)" + echo + echo "To test locally, AGORIC_NET=local and have the following running: +# freshen sdk +cd agoric-sdk +yarn install && yarn build + +# (new tab) +# Start the chain +cd packages/cosmic-swingset +make scenario2-setup scenario2-run-chain-psm + +# (new tab) +cd packages/cosmic-swingset +# Fund the pool +make fund-provision-pool +# Copy the agoric address from your keplr wallet or 'agd keys list', starts with 'agoric1' +KEY= +# Provision your wallet +make ACCT_ADDR=$KEY AGORIC_POWERS=SMART_WALLET fund-acct provision-acct +# verify +agoric wallet list +agoric wallet show --from \$KEY +" + exit 1 +fi + +KEY=$1 + +if [ -z "$KEY" ]; then + echo "USAGE: $0 key" + echo "You can reference by name: agd keys list" + echo "Make sure it has been provisioned by the faucet: https://$AGORIC_NET.faucet.agoric.net/" + echo "and that it's the sole member of economicCommitteeAddresses in decentral-psm-config.json" + exit 1 +fi + +set -x + +# NB: fee percentages must be at least the governed param values + +# Accept invitation to economic committee +COMMITTEE_OFFER=$(mktemp -t agops.XXX) +bin/agops psm committee >|"$COMMITTEE_OFFER" +jq ".body | fromjson" <"$COMMITTEE_OFFER" +agoric wallet send --from "$KEY" --offer "$COMMITTEE_OFFER" +COMMITTEE_OFFER_ID=$(jq ".body | fromjson | .offer.id" <"$COMMITTEE_OFFER") + +# Accept invitation to be a charter member +CHARTER_OFFER=$(mktemp -t agops.XXX) +bin/agops psm charter >|"$CHARTER_OFFER" +jq ".body | fromjson" <"$CHARTER_OFFER" +agoric wallet send --from "$KEY" --offer "$CHARTER_OFFER" +CHARTER_OFFER_ID=$(jq ".body | fromjson | .offer.id" <"$CHARTER_OFFER") + +### Now we have the continuing invitationMakers saved in the wallet + +# Use invitation result, with continuing invitationMakers to propose a vote +PROPOSAL_OFFER=$(mktemp -t agops.XXX) +bin/agops psm proposePauseOffers --substring wantMinted --previousOfferId "$CHARTER_OFFER_ID" >|"$PROPOSAL_OFFER" +jq ".body | fromjson" <"$PROPOSAL_OFFER" +agoric wallet send --from "$KEY" --offer "$PROPOSAL_OFFER" + +# vote on the question that was made +VOTE_OFFER=$(mktemp -t agops.XXX) +bin/agops psm vote --forPosition 0 --previousOfferId "$COMMITTEE_OFFER_ID" >|"$VOTE_OFFER" +jq ".body | fromjson" <"$VOTE_OFFER" +agoric wallet send --from "$KEY" --offer "$VOTE_OFFER" +## wait for the election to be resolved (1m in commands/psm.js) + +# check that the dictatorial vote was executed +# TODO use vote outcome data https://github.com/Agoric/agoric-sdk/issues/6198 +SWAP_OFFER=$(mktemp -t agops.XXX) +bin/agops psm swap --wantMinted 0.01 --feePct 0.01 >|"$SWAP_OFFER" +agoric wallet send --from "$KEY" --offer "$SWAP_OFFER" + +# chain logs should read like: +# vat: v15: walletFactory: { wallet: Object [Alleged: SmartWallet self] {}, actionCapData: { body: '{"method":"executeOffer","offer":{"id":1663182246304,"invitationSpec":{"source":"contract","instance":{"@qclass":"slot","index":0},"publicInvitationMaker":"makeWantMintedInvitation"},"proposal":{"give":{"In":{"brand":{"@qclass":"slot","index":1},"value":{"@qclass":"bigint","digits":"10002"}}},"want":{"Out":{"brand":{"@qclass":"slot","index":2},"value":{"@qclass":"bigint","digits":"10000"}}}}}}', slots: [ 'board04312', 'board0223', 'board0639' ] } } +# vat: v15: wallet agoric109q3uc0xt8aavne94rgd6rfeucavrx924e0ztf starting executeOffer 1663182246304 +# vat: v14: bank balance update { address: 'agoric109q3uc0xt8aavne94rgd6rfeucavrx924e0ztf', amount: '1121979996', denom: 'ibc/usdc1234' } +# ls: v6: Logging sent error stack (Error#1) +# ls: v6: Error#1: not accepting offer with description wantMinted +# ls: v6: Error: not accepting offer with description "wantMinted" diff --git a/packages/agoric-cli/test/agops-smoketest.sh b/packages/agoric-cli/test/agops-perf-smoketest.sh similarity index 100% rename from packages/agoric-cli/test/agops-smoketest.sh rename to packages/agoric-cli/test/agops-perf-smoketest.sh diff --git a/packages/smart-wallet/src/invitations.js b/packages/smart-wallet/src/invitations.js index 4397a112e14..8005f604b15 100644 --- a/packages/smart-wallet/src/invitations.js +++ b/packages/smart-wallet/src/invitations.js @@ -70,11 +70,30 @@ export const makeInvitationsHelper = ( assert(instance && description, 'missing instance or description'); /** @type {Amount<'set'>} */ const purseAmount = await E(invitationsPurse).getCurrentAmount(); - const match = AmountMath.getValue(invitationBrand, purseAmount).find( + const invitations = AmountMath.getValue(invitationBrand, purseAmount); + + const matches = invitations.filter( details => - details.description === description && details.instance === instance, + description === details.description && instance === details.instance, ); - assert(match, `no matching invitation for ${description}`); + if (matches.length === 0) { + // look up diagnostic info + const dCount = invitations.filter( + details => description === details.description, + ).length; + const iCount = invitations.filter( + details => instance === details.instance, + ).length; + assert.fail( + `no invitation match (${dCount} description and ${iCount} instance)`, + ); + } else if (matches.length > 1) { + // TODO? allow further disambiguation + console.warn('multiple invitation matches, picking the first'); + } + + const match = matches[0]; + const toWithDraw = AmountMath.make(invitationBrand, harden([match])); console.log('.... ', { toWithDraw });