diff --git a/packages/agoric-cli/src/sdk-package-names.js b/packages/agoric-cli/src/sdk-package-names.js index 38596769ab4..e710c79a762 100644 --- a/packages/agoric-cli/src/sdk-package-names.js +++ b/packages/agoric-cli/src/sdk-package-names.js @@ -42,5 +42,6 @@ export default [ "@agoric/xsnap-lockdown", "@agoric/zoe", "@agoric/zone", - "agoric" + "agoric", + "inter-cli" ]; diff --git a/packages/inter-cli/jsconfig.json b/packages/inter-cli/jsconfig.json new file mode 100644 index 00000000000..1743e58a2ee --- /dev/null +++ b/packages/inter-cli/jsconfig.json @@ -0,0 +1,10 @@ +// This file can contain .js-specific Typescript compiler config. +{ + "extends": "../../tsconfig.json", + "include": [ + "globals.d.ts", + "scripts/**/*.js", + "src/**/*.js", + "test/**/*.js" + ] +} diff --git a/packages/inter-cli/package.json b/packages/inter-cli/package.json new file mode 100644 index 00000000000..0e80a6ba5ae --- /dev/null +++ b/packages/inter-cli/package.json @@ -0,0 +1,47 @@ +{ + "name": "inter-cli", + "version": "0.1.0", + "description": "TODO", + "type": "module", + "scripts": { + "build": "exit 0", + "test": "ava", + "test:c8": "c8 $C8_OPTIONS ava --config=ava-nesm.config.js", + "lint-fix": "yarn lint:eslint --fix", + "lint": "run-s --continue-on-error lint:*", + "lint:types": "tsc -p jsconfig.json", + "lint:eslint": "eslint ." + }, + "bin": { + "inter-tool": "src/inter-tool.js" + }, + "author": "Agoric", + "license": "Apache-2.0", + "dependencies": { + "@agoric/casting": "^0.4.2", + "@agoric/cosmic-proto": "^0.3.0", + "@agoric/ertp": "^0.16.2", + "@agoric/internal": "^0.3.2", + "@cosmjs/encoding": "^0.30.1", + "@cosmjs/stargate": "^0.30.1", + "@cosmjs/tendermint-rpc": "^0.30.1", + "@endo/far": "^0.2.18", + "@endo/init": "^0.5.56", + "@endo/marshal": "^0.8.5", + "@endo/nat": "^4.1.27", + "@endo/patterns": "^0.2.2", + "anylogger": "^0.21.0", + "commander": "^10.0.0", + "jessie.js": "^0.3.2" + }, + "devDependencies": { + "@fast-check/ava": "^1.1.5" + }, + "ava": { + "files": [ + "test/**/test-*.js" + ], + "timeout": "20m", + "workerThreads": false + } +} diff --git a/packages/inter-cli/src/commands/auction.js b/packages/inter-cli/src/commands/auction.js new file mode 100644 index 00000000000..28be02f48a4 --- /dev/null +++ b/packages/inter-cli/src/commands/auction.js @@ -0,0 +1,144 @@ +// @ts-check +// @jessie-check + +import { M, mustMatch } from '@endo/patterns'; +import { BrandShape, RatioShape } from '@agoric/ertp/src/typeGuards.js'; +import { makeBoardClient } from '../lib/boardClient.js'; +import { makeVstorageQueryService } from '../lib/vstorage.js'; +import { formatTimestamp, makeAssetFormatters } from '../lib/format.js'; + +// XXX push down into @agoric/ERTP? +const NatAmountShape = harden({ brand: BrandShape, value: M.nat() }); +const TimeStampShape = { + timerBrand: M.remotable('timerBrand'), + absValue: M.nat(), +}; + +const bidData = harden({ + timestamp: TimeStampShape, + sequence: M.nat(), + balance: NatAmountShape, + wanted: NatAmountShape, + exitAfterBuy: M.boolean(), +}); + +const shapeLeaves = harden({ + ScaledBidData: { + bidScaling: RatioShape, + ...bidData, + }, + + PricedBidData: { + price: RatioShape, + ...bidData, + }, +}); + +const shape = harden({ + ...shapeLeaves, + BidDataNotification: M.arrayOf( + M.or(shapeLeaves.ScaledBidData, shapeLeaves.PricedBidData), + ), +}); + +/** + * + * @param {unknown} specimen + * @returns { asserts specimen is BidDataNotification } + * + * XXX contract should export this type: + * + * @typedef {{ + * balance: Amount<'nat'>, + * exitAfterBuy: boolean, + * sequence: bigint, + * timestamp: import('@agoric/time').Timestamp, + * wanted: Amount<'nat'>, + * } & ({ price: Ratio } | { bidScaling: Ratio})} BidData1 + * @typedef {BidData1[]} BidDataNotification + */ +const assertBidDataNotification = specimen => { + mustMatch(specimen, shape.BidDataNotification); +}; + +/** + * + * @param {ScaledBidData | PricedBidData} bid + * @param {ReturnType} fmt + * + * @typedef {import('@agoric/inter-protocol/src/auction/auctionBook.js').ScaledBidData} ScaledBidData + * @typedef {import('@agoric/inter-protocol/src/auction/auctionBook.js').PricedBidData} PricedBidData + */ +const fmtBid = (bid, fmt) => { + const { timestamp, sequence, balance, wanted, exitAfterBuy, ...more } = bid; + const p = 'price' in bid ? { price: fmt.price(bid.price) } : {}; + const b = 'bidScaling' in bid ? { bidScaling: fmt.rate(bid.bidScaling) } : {}; + const exit = exitAfterBuy ? { exitAfterBuy } : {}; + // @ts-expect-error XXX how to do this? + const { price: _p, bidScaling: _b, ...rest } = more; + const info = harden({ + // assume timerBrand gives values in seconds + timestamp: formatTimestamp(timestamp.absValue), + sequence: Number(sequence), + ...p, + ...b, + balance: fmt.amount(balance), + wanted: fmt.amount(wanted), + ...exit, + ...rest, + }); + return info; +}; + +/** + * + * @param {import('commander').Command} interCmd + * @param {object} io + * @param {import('../lib/tui').TUI} io.tui + * @param {() => Promise>} io.getBatchQuery + * @param {() => Promise} io.makeRpcClient + * + * @typedef {import('@cosmjs/tendermint-rpc').RpcClient} RpcClient + */ +export const addBidCommand = ( + interCmd, + { tui, getBatchQuery, makeRpcClient }, +) => { + const bidCmd = interCmd + .command('bid') + .description('Add a Bid command/operation'); + + const makeBoard = () => getBatchQuery().then(makeBoardClient); + + bidCmd + .command('list') + .description('List Bids operation') + .option('--book', 'auction book number', Number, 0) + .action(async (/** @type {{book: number}} */ { book }) => { + const bidsPart = 'schedule'; // XXX something goofy is going on in the contract + const [board, queryService] = await Promise.all([ + makeBoard(), + makeRpcClient().then(makeVstorageQueryService), + ]); + const agoricNames = await board.provideAgoricNames(); + + const { children: bidKeys } = await queryService.Children({ + path: `published.auction.book${book}.${bidsPart}`, + }); + const { length: n } = bidKeys; + const more = n > 3 ? '...' : ''; + console.warn('fetching', n, 'bids:', bidKeys.slice(0, 3), more); + + console.warn('TODO: pagination'); + const bids = await board.readBatch( + bidKeys.map(k => `published.auction.book${book}.${bidsPart}.${k}`), + ); + assertBidDataNotification(bids); + const fmt = makeAssetFormatters(agoricNames.vbankAsset); + for (const bid of bids) { + tui.show(fmtBid(bid, fmt)); + } + }); + + return bidCmd; +}; diff --git a/packages/inter-cli/src/inter-tool.js b/packages/inter-cli/src/inter-tool.js new file mode 100755 index 00000000000..5a1de3c317e --- /dev/null +++ b/packages/inter-cli/src/inter-tool.js @@ -0,0 +1,76 @@ +#!/bin/env node +// @ts-check +// @jessie-check +/* global globalThis */ + +import '@endo/init'; +import process from 'process'; + +import anylogger from 'anylogger'; +import { createCommand, CommanderError } from 'commander'; + +import { makeHttpClient } from '@agoric/casting/src/makeHttpClient.js'; + +import { makeTUI } from './lib/tui.js'; +import { addBidCommand } from './commands/auction.js'; +import { getNetworkConfig } from './lib/networkConfig.js'; +import { makeBatchQuery } from './lib/vstorage.js'; + +const DISCLAIMER = + 'Source code licensed under Apache 2.0. Use at your own risk.'; + +/** + * Create and run the inter command, + * portioning out authority as needed. + */ +const main = async () => { + const logger = anylogger('inter'); + const tui = makeTUI({ stdout: process.stdout, logger }); + + const { env } = process; + env.ACK_IST_RISK || tui.warn(DISCLAIMER); + + let config; + // NOTE: delay getNetworkConfig() and all other I/O + // until a command .action() is run + const provideConfig = async () => { + await null; + if (config) return config; + config = await getNetworkConfig(env, { fetch: globalThis.fetch }); + return config; + }; + + const pick = xs => xs[Math.floor(Math.random() * xs.length)]; + + // cosmjs-based RPC client is only used for .Children() + const makeRpcClient = () => + provideConfig().then(c => { + const rpcAddr = pick(c.rpcAddrs); + return makeHttpClient(rpcAddr, globalThis.fetch); + }); + + // cosmjs/protobuf tooling doesn't support batch query, + // so we re-implement it using fetch(). + const getBatchQuery = () => + provideConfig().then(({ rpcAddrs }) => + makeBatchQuery(globalThis.fetch, rpcAddrs), + ); + + const interCmd = createCommand('inter-tool').description( + 'Inter Protocol auction bid query', + ); + addBidCommand(interCmd, { tui, getBatchQuery, makeRpcClient }); + + try { + interCmd.parseAsync(process.argv); + } catch (err) { + if (err instanceof CommanderError) { + console.error(err.message); + } else { + console.error(err); // CRASH! show stack trace + } + process.exit(1); + } +}; + +main(); diff --git a/packages/inter-cli/src/lib/agd-lib.js b/packages/inter-cli/src/lib/agd-lib.js new file mode 100644 index 00000000000..a67f3086cea --- /dev/null +++ b/packages/inter-cli/src/lib/agd-lib.js @@ -0,0 +1,96 @@ +// @ts-check +// @jessie-check + +const { freeze } = Object; + +const agdBinary = 'agd'; + +/** @param {{ execFileSync: typeof import('child_process').execFileSync }} io */ +export const makeAgd = ({ execFileSync }) => { + console.warn('XXX is sync IO essential?'); + + /** @param {{ home?: string, keyringBackend?: string, rpcAddrs?: string[] }} keyringOpts */ + const make = ({ home, keyringBackend, rpcAddrs } = {}) => { + const keyringArgs = [ + ...(home ? ['--home', home] : []), + ...(keyringBackend ? [`--keyring-backend`, keyringBackend] : []), + ]; + console.warn('XXX: rpcAddrs after [0] are ignored'); + const nodeArgs = [...(rpcAddrs ? [`--node`, rpcAddrs[0]] : [])]; + + const l = a => { + console.log(a); // XXX unilateral logging by a library... iffy + return a; + }; + /** + * @param {string[]} args + * @param {*} [opts] + */ + const exec = (args, opts) => + execFileSync(agdBinary, l(args), opts).toString(); + + const outJson = ['--output', 'json']; + + const ro = freeze({ + status: async () => JSON.parse(exec([...nodeArgs, 'status'])), + /** + * @param {[kind: 'tx', txhash: string]} qArgs + */ + query: async qArgs => { + const out = await exec(['query', ...qArgs, ...nodeArgs, ...outJson], { + stdio: ['ignore', 'pipe', 'ignore'], + }); + return JSON.parse(out); + }, + }); + const nameHub = freeze({ + /** + * @param {string[]} path + * NOTE: synchronous I/O + */ + lookup: (...path) => { + if (!Array.isArray(path)) { + // TODO: use COND || Fail`` + throw TypeError(); + } + if (path.length !== 1) { + throw Error(`path length limited to 1: ${path.length}`); + } + const [name] = path; + const txt = exec(['keys', 'show', `--address`, name, ...keyringArgs]); + return txt.trim(); + }, + }); + const rw = freeze({ + /** + * TODO: gas + * + * @param {string[]} txArgs + * @param {{ chainId: string, from: string, yes?: boolean }} opts + */ + tx: async (txArgs, { chainId, from, yes }) => { + const yesArg = yes ? ['--yes'] : []; + const args = [ + ...nodeArgs, + ...[`--chain-id`, chainId], + ...keyringArgs, + ...[`--from`, from], + 'tx', + ...txArgs, + ...['--broadcast-mode', 'block'], + ...yesArg, + ...outJson, + ]; + const out = exec(args); + return JSON.parse(out); + }, + ...ro, + ...nameHub, + readOnly: () => ro, + nameHub: () => nameHub, + withOpts: opts => make({ home, keyringBackend, rpcAddrs, ...opts }), + }); + return rw; + }; + return make(); +}; diff --git a/packages/inter-cli/src/lib/boardClient.js b/packages/inter-cli/src/lib/boardClient.js new file mode 100644 index 00000000000..875be584e50 --- /dev/null +++ b/packages/inter-cli/src/lib/boardClient.js @@ -0,0 +1,209 @@ +// @ts-check +import { Far } from '@endo/far'; +import { makeMarshal } from '@endo/marshal'; +import { assertCapData } from '@agoric/internal/src/lib-chainStorage.js'; +import { + BrandShape, + DisplayInfoShape, + IssuerShape, +} from '@agoric/ertp/src/typeGuards.js'; +import { M, mustMatch } from '@endo/patterns'; +import { zip } from '@agoric/internal'; +import { extractStreamCellValue } from './vstorage.js'; + +const { Fail } = assert; + +export const makeBoardContext = () => { + /** @type {Map} */ + const idToValue = new Map(); + /** @type {Map} */ + const valueToId = new Map(); + + /** + * Provide a remotable for each slot. + * + * @param {string} slot + * @param {string} [iface] non-empty if present + */ + const provide = (slot, iface) => { + if (idToValue.has(slot)) { + return idToValue.get(slot) || Fail`cannot happen`; // XXX check this statically? + } + if (!iface) throw Fail`1st occurrence must provide iface`; + const json = { _: iface }; + // XXX ok to leave iface alone? + /** @type {{}} */ + const value = Far(iface, { toJSON: () => json }); + idToValue.set(slot, value); + valueToId.set(value, slot); + return value; + }; + + /** Read-only board */ + const board = { + /** @param {unknown} value */ + getId: value => { + valueToId.has(value) || Fail`unknown value: ${value}`; + return valueToId.get(value) || Fail`cannot happen`; // XXX check this statically? + }, + + /** @param {string} id */ + getValue: id => { + assert.typeof(id, 'string'); + idToValue.has(id) || Fail`unknown id: ${id}`; + return idToValue.get(id) || Fail`cannot happen`; // XXX check this statically? + }, + }; + + const marshaller = makeMarshal(board.getId, provide, { + serializeBodyFormat: 'smallcaps', + }); + + return harden({ + board, + register: provide, + marshaller, + /** + * Unmarshall capData, creating a Remotable for each boardID slot. + * + * @type {(cd: import("@endo/marshal").CapData) => unknown } + */ + ingest: marshaller.fromCapData, + }); +}; + +/** @param {QueryDataResponseT} queryDataResponse */ +export const extractCapData = queryDataResponse => { + const cellValue = extractStreamCellValue(queryDataResponse); + const capData = harden(JSON.parse(cellValue)); + assertCapData(capData); + return capData; +}; + +// XXX where is this originally defined? vat-bank? +/** + * @typedef {{ + * brand: Brand<'nat'>, + * denom: string, + * displayInfo: DisplayInfo, + * issuer: Issuer<'nat'>, + * issuerName: string, + * proposedName: string, + * }} VBankAssetDetail + */ +const AssetDetailShape = harden({ + brand: BrandShape, + denom: M.string(), + displayInfo: DisplayInfoShape, + issuer: IssuerShape, + issuerName: M.string(), + proposedName: M.string(), +}); +const InstanceShape = M.remotable('Instance'); +const kindInfo = /** @type {const} */ ({ + brand: { + shape: BrandShape, + coerce: x => /** @type {Brand} */ (x), + }, + instance: { + shape: InstanceShape, + coerce: x => /** @type {Instance} */ (x), + }, + vbankAsset: { + shape: AssetDetailShape, + coerce: x => /** @type {VBankAssetDetail} */ (x), + }, +}); + +/** + * @param {ReturnType} boardCtx + * @param {ReturnType} batchQuery + */ +const makeAgoricNames = async (boardCtx, batchQuery) => { + const kinds = Object.keys(kindInfo); + const { values: responses, errors } = await batchQuery( + kinds.map(kind => ({ + kind: 'data', + path: `published.agoricNames.${kind}`, + })), + ); + for (const [kind, err] of zip(kinds, errors)) { + if (!err) continue; + console.warn(kind, err); + } + const kindData = Object.fromEntries(zip(kinds, responses)); + + /** + * @template T + * @param {keyof typeof kindInfo} kind + * @param {(x: any) => T} _coerce + */ + const ingestKind = (kind, _coerce) => { + const queryDataResponse = kindData[kind]; + const capData = extractCapData(queryDataResponse); + const xs = boardCtx.ingest(capData); + mustMatch(xs, M.arrayOf([M.string(), kindInfo[kind].shape])); + /** @type {[string, ReturnType][]} */ + // @ts-expect-error runtime checked + const entries = xs; + const record = harden(Object.fromEntries(entries)); + return record; + }; + const agoricNames = harden({ + brand: ingestKind('brand', kindInfo.brand.coerce), + instance: ingestKind('instance', kindInfo.instance.coerce), + vbankAsset: ingestKind('vbankAsset', kindInfo.vbankAsset.coerce), + }); + return agoricNames; +}; + +/** + * from @agoric/cosmic-proto/vstorage + * + * XXX import('@agoric/cosmic-proto/vstorage/query').QueryDataResponse doesn't worksomehow + * + * @typedef {Awaited>} QueryDataResponseT + */ + +/** + * A boardClient unmarshals vstorage query responses preserving object identiy. + * + * @param {ReturnType} batchQuery + */ +export const makeBoardClient = batchQuery => { + const boardCtx = makeBoardContext(); + /** @type {Awaited>} */ + let agoricNames; + + /** + * @param {string[]} paths + * @param {(path: string, msg: string) => void} [warn] + */ + const readBatch = async (paths, warn = console.warn) => { + const { values, errors } = await batchQuery( + paths.map(path => ({ kind: 'data', path })), + ); + for (let ix = 0; ix < errors.length; ix += 1) { + if (!errors[+ix]) { + continue; + } + warn(paths[+ix], errors[+ix]); + } + return harden(values.map(v => boardCtx.ingest(extractCapData(v)))); + }; + + const fatal = (path, err) => { + throw Error(`cannot get Data of ${path}: ${err}`); + }; + return harden({ + batchQuery, + provideAgoricNames: async () => { + if (agoricNames) return agoricNames; + agoricNames = await makeAgoricNames(boardCtx, batchQuery); + return agoricNames; + }, + readBatch, + /** @type {(path: string) => Promise} */ + readLatestHead: path => readBatch([path], fatal), + }); +}; diff --git a/packages/inter-cli/src/lib/format.js b/packages/inter-cli/src/lib/format.js new file mode 100644 index 00000000000..7cd23958324 --- /dev/null +++ b/packages/inter-cli/src/lib/format.js @@ -0,0 +1,125 @@ +// @ts-check +// @jessie-check +import { Nat } from '@endo/nat'; +import { makeMap } from 'jessie.js'; + +const { Fail } = assert; + +/** + * @param {NatValue} a + * @param {NatValue} b + */ +const gcd = (a, b) => { + if (b === 0n) { + return a; + } + + return gcd(b, a % b); +}; + +/** + * + * @param {Record} vbankAssets + */ +export const makeAssetFormatters = vbankAssets => { + const byBrand = makeMap(Object.values(vbankAssets).map(a => [a.brand, a])); + + /** @param {Brand} brand */ + const getDetail = brand => { + const detail = byBrand.get(brand); + if (!detail) { + throw Fail`unknown brand: ${brand}`; + } + return detail; + }; + + /** @param {import("./boardClient").VBankAssetDetail} detail */ + const getDecimalPlaces = detail => { + const decimalPlaces = detail?.displayInfo?.decimalPlaces || 0; + return decimalPlaces; + }; + + /** + * @param {NatValue} value + * @param {number} decimalPlaces + */ + const decimal = (value, decimalPlaces) => { + const rawNumeral = `${value}`; + const digits = rawNumeral.length; + const whole = + digits > decimalPlaces + ? rawNumeral.slice(0, digits - decimalPlaces) + : '0'; + const frac = ('0'.repeat(decimalPlaces) + rawNumeral) + .slice(-decimalPlaces) + .replace(/0+$/, ''); + const dot = frac.length > 0 ? '.' : ''; + return `${whole}${dot}${frac}`; + }; + + /** @param {Amount<'nat'>} amt */ + const amount = ({ brand, value }) => { + const detail = getDetail(brand); + const decimalPlaces = getDecimalPlaces(detail); + return `${decimal(value, decimalPlaces)} ${detail.issuerName}`; + }; + + /** + * @param {Ratio} x + * @param {number} [showDecimals] + */ + const rate = ({ numerator, denominator }, showDecimals = 4) => { + numerator.brand === denominator.brand || Fail`brands differ in rate`; + const pct = (100 * Number(numerator.value)) / Number(denominator.value); + return `${pct.toFixed(showDecimals)}%`; + }; + + /** + * @param {Ratio} x + * @param {number} [showDecimals] + */ + const price = ({ numerator, denominator }, showDecimals = 4) => { + const detail = { + top: getDetail(numerator.brand), + bot: getDetail(denominator.brand), + }; + const decimaPlaces = { + top: getDecimalPlaces(detail.top), + bot: getDecimalPlaces(detail.bot), + }; + const f = gcd(numerator.value, denominator.value); + const scaled = { + top: numerator.value / f, + bot: denominator.value / f, + shift: 10 ** (decimaPlaces.bot - decimaPlaces.top), + }; + Number.isSafeInteger(Number(scaled.top)) || + Fail`too big to divide: ${scaled.top}`; + Number.isSafeInteger(Number(scaled.bot)) || + Fail`too big to divide: ${scaled.bot}`; + scaled.bot > 0n || Fail`cannot divide by 0`; + const x = Number(scaled.top) / (Number(scaled.bot) * scaled.shift); + // TODO: format ratios > 1e20 nicely + const xStr = x.toFixed(showDecimals); + if (numerator.brand === denominator.brand) { + return `${xStr}`; + } + return `${xStr} ${detail.top.issuerName}/${detail.bot.issuerName}`; + }; + + return harden({ + hasBrand: b => byBrand.has(b), + amount, + price, + rate, + }); +}; + +/** @param {NatValue} seconds */ +export const formatTimestamp = seconds => { + Nat(seconds); + const ms = Number(seconds) * 1000; + const iso = new Date(ms).toISOString(); + const noFrac = iso.replace(/\.000Z$/, 'Z'); + return noFrac; +}; diff --git a/packages/inter-cli/src/lib/networkConfig.js b/packages/inter-cli/src/lib/networkConfig.js new file mode 100644 index 00000000000..3d781cb5dc6 --- /dev/null +++ b/packages/inter-cli/src/lib/networkConfig.js @@ -0,0 +1,56 @@ +// @ts-check +import { M, mustMatch } from '@endo/patterns'; + +/** + * @typedef {{ rpcAddrs: string[], chainName: string }} MinimalNetworkConfig + */ + +const { freeze } = Object; +const localConfig = freeze({ + rpcAddrs: ['http://0.0.0.0:26657'], + chainName: 'agoriclocal', +}); + +export const networkConfigUrl = agoricNetSubdomain => + `https://${agoricNetSubdomain}.agoric.net/network-config`; +export const rpcUrl = agoricNetSubdomain => + `https://${agoricNetSubdomain}.rpc.agoric.net:443`; + +const ConfigShape = M.splitRecord({ + rpcAddrs: M.arrayOf(M.string()), + chainName: M.string(), +}); + +/** + * @param {typeof process.env} env + * @param {object} io + * @param {typeof window.fetch} io.fetch + * @returns {Promise} + */ +export const getNetworkConfig = async (env, { fetch }) => { + const { AGORIC_NET = 'local' } = env; + if (AGORIC_NET === 'local') { + return localConfig; + } + + /** + * @param {string} str + * @returns {Promise} + */ + const fromAgoricNet = async str => { + const [netName, chainName] = str.split(','); + if (chainName) { + return freeze({ chainName, rpcAddrs: [rpcUrl(netName)] }); + } + const config = await fetch(networkConfigUrl(netName)).then(res => + res.json(), + ); + harden(config); + mustMatch(config, ConfigShape); + return config; + }; + + return fromAgoricNet(AGORIC_NET).catch(err => { + throw Error(`cannot get network config (${AGORIC_NET}): ${err.message}`); + }); +}; diff --git a/packages/inter-cli/src/lib/tui.js b/packages/inter-cli/src/lib/tui.js new file mode 100644 index 00000000000..947fc827ac7 --- /dev/null +++ b/packages/inter-cli/src/lib/tui.js @@ -0,0 +1,39 @@ +// @ts-check +// @jessie-check + +/** + * JSON.stringify replacer to handle bigint + * + * @param {unknown} k + * @param {unknown} v + */ +export const bigintReplacer = (k, v) => (typeof v === 'bigint' ? `${v}` : v); + +/** + * TUI - a Textual User Interface + * + * @param {{ + * stdout: Pick, + * logger: Pick, + * }} io + * @typedef {ReturnType} TUI + */ +export const makeTUI = ({ stdout, logger }) => { + /** + * write info as JSON + * + * @param {unknown} info JSON.strigify()-able data (bigint replaced with string) + * @param {boolean} [indent] normally false, keeping the JSON on one line + */ + const show = (info, indent = false) => { + stdout.write( + `${JSON.stringify(info, bigintReplacer, indent ? 2 : undefined)}\n`, + ); + }; + + return Object.freeze({ + show, + /** @type {typeof console.warn} */ + warn: (...args) => logger.warn(...args), + }); +}; diff --git a/packages/inter-cli/src/lib/vstorage.js b/packages/inter-cli/src/lib/vstorage.js new file mode 100644 index 00000000000..2f0c1e2bd89 --- /dev/null +++ b/packages/inter-cli/src/lib/vstorage.js @@ -0,0 +1,115 @@ +// @ts-check +/* global Buffer */ + +import { Tendermint34Client } from '@cosmjs/tendermint-rpc'; +import { createProtobufRpcClient, QueryClient } from '@cosmjs/stargate'; +import { + QueryClientImpl, + QueryDataResponse, +} from '@agoric/cosmic-proto/vstorage/query.js'; +import { isStreamCell } from '@agoric/internal/src/lib-chainStorage.js'; + +const { Fail } = assert; + +/** @param {import('@cosmjs/tendermint-rpc').RpcClient} rpcClient */ +export const makeVstorageQueryService = async rpcClient => { + const tmClient = await Tendermint34Client.create(rpcClient); + const qClient = new QueryClient(tmClient); + const rpc = createProtobufRpcClient(qClient); + const queryService = new QueryClientImpl(rpc); + return queryService; +}; + +/** + * Extract one value from a the vstorage stream cell in a QueryDataResponse + * + * @param {QueryDataResponse} data + * @param {number} [index] index of the desired value in a deserialized stream cell + * + * XXX import('@agoric/cosmic-proto/vstorage/query').QueryDataResponse doesn't worksomehow + * @typedef {Awaited>} QueryDataResponseT + */ +export const extractStreamCellValue = (data, index = -1) => { + const { value: serialized } = QueryDataResponse.fromJSON(data); + + serialized.length > 0 || Fail`no StreamCell values: ${data}`; + + const streamCell = JSON.parse(serialized); + if (!isStreamCell(streamCell)) { + throw Fail`not a StreamCell: ${streamCell}`; + } + + const { values } = streamCell; + values.length > 0 || Fail`no StreamCell values: ${streamCell}`; + + const value = values.at(index); + assert.typeof(value, 'string'); + return value; +}; +harden(extractStreamCellValue); + +/** + * @param {{ kind:PathKind, path:string }[]} queries + * @returns {JsonRpcRequest[]} + * + * @typedef {import('@cosmjs/json-rpc').JsonRpcRequest} JsonRpcRequest + */ +export const vstorageRequests = queries => + queries.map(({ kind, path }, index) => ({ + jsonrpc: '2.0', + id: index, + method: 'abci_query', + params: { path: `/custom/vstorage/${kind}/${path}` }, + })); + +/** + * @param {typeof window.fetch} fetch + * @param {string[]} nodes + * + * @typedef {'children' | 'data'} PathKind + */ +export const makeBatchQuery = (fetch, nodes) => { + let nodeIx = 0; + /** + * @param {{ kind:PathKind, path:string }[]} queries + * @returns {Promise<{ values: QueryDataResponse[], errors: string[] }>} + */ + const batchQuery = async queries => { + const requests = vstorageRequests(queries); + nodeIx = (nodeIx + 1) % nodes.length; + const node = nodes[nodeIx]; + const res = await fetch(node, { + method: 'POST', + body: JSON.stringify(requests), + }); + if (res.status >= 400) { + throw Error(res.statusText); + } + const data = await res.json(); + const responses = Array.isArray(data) ? data : [data]; + const values = []; + const errors = []; + for (const item of responses) { + if (typeof item?.result?.response !== 'object') { + throw Error( + `JSON RPC error: ${typeof item}.${typeof item?.result}.${typeof item + ?.result?.response}`, + ); + } + const { + id, + result: { response }, + } = item; + if (response.code !== 0) { + errors[id] = response.log; + continue; + } + typeof response.value === 'string' || + Fail`JSON RPC value must be string, not ${typeof response.value}`; + const decoded = Buffer.from(response.value, 'base64').toString(); + values[id] = QueryDataResponse.fromJSON(JSON.parse(decoded)); + } + return { values, errors }; + }; + return batchQuery; +}; diff --git a/packages/inter-cli/test/arbPassableKey.js b/packages/inter-cli/test/arbPassableKey.js new file mode 100644 index 00000000000..216a1965e11 --- /dev/null +++ b/packages/inter-cli/test/arbPassableKey.js @@ -0,0 +1,54 @@ +// @ts-check +import { Far } from '@endo/far'; +import { fc } from '@fast-check/ava'; +import { makeTagged } from '@endo/marshal'; + +const arbPrim = fc.oneof( + fc.constant(undefined), + fc.constant(null), + fc.boolean(), + fc.float(), + fc.bigInt(), + fc.string(), + fc.string().map(n => Symbol.for(n)), // TODO: well-known symbols +); + +const arbRemotable = fc.string().map(iface => Far(iface)); +// const arbPromise = fc.nat(16).map(_ => harden(new Promise(_resolve => {}))); +// const arbCap = fc.oneof(arbRemotable, arbPromise); +// const arbError = fc.string().map(msg => new Error(msg)); + +// const arbAtom = fc.oneof(arbPrim, arbCap, arbError); + +// const { passable: arbPassable } = fc.letrec(tie => ({ +// passable: fc.oneof(arbAtom, tie('copyArray'), tie('copyRecord')), +// copyArray: fc.array(tie('passable')).map(harden), +// copyRecord: fc.dictionary(fc.string(), tie('passable')).map(harden), +// tagged: fc +// .record({ tag: fc.string(), payload: tie('passable') }) +// .map(({ tag, payload }) => makeTagged(tag, payload)), +// })); + +/** + * "Keys are Passable arbitrarily-nested pass-by-copy containers + * (CopyArray, CopyRecord, CopySet, CopyBag, CopyMap) in which every + * non-container leaf is either a Passable primitive value or a Remotable" + * + * See import('@endo/patterns').Key + */ +const { passable: arbKey } = fc.letrec(tie => ({ + // NOTE: no published values are copySet, copyMap, or copyBag (yet?) + passable: fc.oneof( + arbPrim, + arbRemotable, + tie('copyArray'), + tie('copyRecord'), + ), + copyArray: fc.array(tie('passable')).map(harden), + copyRecord: fc.dictionary(fc.string(), tie('passable')).map(harden), + tagged: fc + .record({ tag: fc.string(), payload: tie('passable') }) + .map(({ tag, payload }) => makeTagged(tag, payload)), +})); + +export { arbPrim, arbRemotable, arbKey }; diff --git a/packages/inter-cli/test/rpc-fixture.js b/packages/inter-cli/test/rpc-fixture.js new file mode 100644 index 00000000000..dff4a986ddf --- /dev/null +++ b/packages/inter-cli/test/rpc-fixture.js @@ -0,0 +1,207 @@ +/** + * @file to regenerate + * 1. set RECORDING=true in test-auction.js + * 2. run: yarn test test/test-auction.js --update-snapshots + * 3. copy the map from test-auction.js.md below + * 4. replace all occurences of : with : + * 5. change RECORDING back to false + */ +export const listBidsRPC = new Map( + Object.entries({ + '["http://0.0.0.0:26657",{"method":"POST","body":"[{\\"jsonrpc\\":\\"2.0\\",\\"id\\":1304155529,\\"method\\":\\"abci_query\\",\\"params\\":{\\"path\\":\\"/custom/vstorage/data/published.agoricNames.brand\\"}},{\\"jsonrpc\\":\\"2.0\\",\\"id\\":1,\\"method\\":\\"abci_query\\",\\"params\\":{\\"path\\":\\"/custom/vstorage/data/published.agoricNames.instance\\"}},{\\"jsonrpc\\":\\"2.0\\",\\"id\\":2,\\"method\\":\\"abci_query\\",\\"params\\":{\\"path\\":\\"/custom/vstorage/data/published.agoricNames.vbankAsset\\"}}]"}]': + [ + { + id: 0, + jsonrpc: '2.0', + result: { + response: { + code: 0, + codespace: '', + height: '7424', + index: '0', + info: '', + key: null, + log: '', + proofOps: null, + valueBlockHeight: '17', + valueCapData: { + body: '#[["AUSD","$0.Alleged: AUSD brand"],["BLD","$1.Alleged: BLD brand"],["IST","$2.Alleged: IST brand"],["Invitation","$3.Alleged: Zoe Invitation brand"],["USDC_axl","$4.Alleged: USDC_axl brand"],["USDC_grv","$5.Alleged: USDC_grv brand"],["USDT_axl","$6.Alleged: USDT_axl brand"],["USDT_grv","$7.Alleged: USDT_grv brand"],["timer","$8.Alleged: timerBrand"],["ATOM","$9.Alleged: ATOM brand"]]', + slots: [ + 'board04542', + 'board0566', + 'board0257', + 'board0074', + 'board01034', + 'board05736', + 'board03138', + 'board03040', + 'board0425', + 'board02152', + ], + }, + }, + }, + }, + { + id: 1, + jsonrpc: '2.0', + result: { + response: { + code: 0, + codespace: '', + height: '7424', + index: '0', + info: '', + key: null, + log: '', + proofOps: null, + valueBlockHeight: '17', + valueCapData: { + body: '#[["ATOM-USD price feed","$0.Alleged: InstanceHandle"],["VaultFactory","$1.Alleged: InstanceHandle"],["VaultFactoryGovernor","$2.Alleged: InstanceHandle"],["auctioneer","$3.Alleged: InstanceHandle"],["econCommitteeCharter","$4.Alleged: InstanceHandle"],["economicCommittee","$5.Alleged: InstanceHandle"],["feeDistributor","$6.Alleged: InstanceHandle"],["provisionPool","$7.Alleged: InstanceHandle"],["psm-IST-AUSD","$8.Alleged: InstanceHandle"],["psm-IST-USDC_axl","$9.Alleged: InstanceHandle"],["psm-IST-USDC_grv","$10.Alleged: InstanceHandle"],["psm-IST-USDT_axl","$11.Alleged: InstanceHandle"],["psm-IST-USDT_grv","$12.Alleged: InstanceHandle"],["reserve","$13.Alleged: InstanceHandle"],["reserveGovernor","$14.Alleged: InstanceHandle"],["walletFactory","$15.Alleged: InstanceHandle"]]', + slots: [ + 'board06458', + 'board00855', + 'board01867', + 'board04154', + 'board02656', + 'board06445', + 'board05557', + 'board01759', + 'board06366', + 'board05262', + 'board02963', + 'board01664', + 'board03365', + 'board06053', + 'board00360', + 'board04661', + ], + }, + }, + }, + }, + { + id: 2, + jsonrpc: '2.0', + result: { + response: { + code: 0, + codespace: '', + height: '7424', + index: '0', + info: '', + key: null, + log: '', + proofOps: null, + valueBlockHeight: '17', + valueCapData: { + body: '#[["ibc/toyatom",{"brand":"$0.Alleged: ATOM brand","denom":"ibc/toyatom","displayInfo":{"assetKind":"nat","decimalPlaces":6},"issuer":"$1.Alleged: ATOM issuer","issuerName":"ATOM","proposedName":"ATOM"}],["ibc/toyellie",{"brand":"$2.Alleged: AUSD brand","denom":"ibc/toyellie","displayInfo":{"assetKind":"nat","decimalPlaces":6},"issuer":"$3.Alleged: AUSD issuer","issuerName":"AUSD","proposedName":"Anchor USD"}],["ibc/toyollie",{"brand":"$4.Alleged: USDT_grv brand","denom":"ibc/toyollie","displayInfo":{"assetKind":"nat","decimalPlaces":6},"issuer":"$5.Alleged: USDT_grv issuer","issuerName":"USDT_grv","proposedName":"Tether USD"}],["ibc/toyusdc",{"brand":"$6.Alleged: USDC_axl brand","denom":"ibc/toyusdc","displayInfo":{"assetKind":"nat","decimalPlaces":6},"issuer":"$7.Alleged: USDC_axl issuer","issuerName":"USDC_axl","proposedName":"USD Coin"}],["ibc/usdc5678",{"brand":"$8.Alleged: USDC_grv brand","denom":"ibc/usdc5678","displayInfo":{"assetKind":"nat","decimalPlaces":6},"issuer":"$9.Alleged: USDC_grv issuer","issuerName":"USDC_grv","proposedName":"USC Coin"}],["ibc/usdt1234",{"brand":"$10.Alleged: USDT_axl brand","denom":"ibc/usdt1234","displayInfo":{"assetKind":"nat","decimalPlaces":6},"issuer":"$11.Alleged: USDT_axl issuer","issuerName":"USDT_axl","proposedName":"Tether USD"}],["ubld",{"brand":"$12.Alleged: BLD brand","denom":"ubld","displayInfo":{"assetKind":"nat","decimalPlaces":6},"issuer":"$13.Alleged: BLD issuer","issuerName":"BLD","proposedName":"Agoric staking token"}],["uist",{"brand":"$14.Alleged: IST brand","denom":"uist","displayInfo":{"assetKind":"nat","decimalPlaces":6},"issuer":"$15.Alleged: IST issuer","issuerName":"IST","proposedName":"Agoric stable token"}]]', + slots: [ + 'board02152', + 'board01151', + 'board04542', + 'board00443', + 'board03040', + 'board05141', + 'board01034', + 'board03935', + 'board05736', + 'board02437', + 'board03138', + 'board05039', + 'board0566', + 'board0592', + 'board0257', + 'board0223', + ], + }, + }, + }, + }, + ], + '["http://0.0.0.0:26657",{"method":"POST","body":"{\\"jsonrpc\\":\\"2.0\\",\\"id\\":33293090,\\"method\\":\\"abci_query\\",\\"params\\":{\\"path\\":\\"/agoric.vstorage.Query/Children\\",\\"data\\":\\"0a207075626c69736865642e61756374696f6e2e626f6f6b302e7363686564756c65\\",\\"prove\\":false}}","headers":{"Content-Type":"application/json"}}]': + { + id: 575919277171, + jsonrpc: '2.0', + result: { + response: { + code: 0, + codespace: '', + height: '7424', + index: '0', + info: '', + key: null, + log: '', + proofOps: null, + value: 'CgdiaWQxMDAxCgdiaWQxMDAyCgdiaWQxMDAz', + }, + }, + }, + '["http://0.0.0.0:26657",{"method":"POST","body":"[{\\"jsonrpc\\":\\"2.0\\",\\"id\\":1358144711,\\"method\\":\\"abci_query\\",\\"params\\":{\\"path\\":\\"/custom/vstorage/data/published.auction.book0.schedule.bid1001\\"}},{\\"jsonrpc\\":\\"2.0\\",\\"id\\":1,\\"method\\":\\"abci_query\\",\\"params\\":{\\"path\\":\\"/custom/vstorage/data/published.auction.book0.schedule.bid1002\\"}},{\\"jsonrpc\\":\\"2.0\\",\\"id\\":2,\\"method\\":\\"abci_query\\",\\"params\\":{\\"path\\":\\"/custom/vstorage/data/published.auction.book0.schedule.bid1003\\"}}]"}]': + [ + { + id: 0, + jsonrpc: '2.0', + result: { + response: { + code: 0, + codespace: '', + height: '7424', + index: '0', + info: '', + key: null, + log: '', + proofOps: null, + valueBlockHeight: '7414', + valueCapData: { + body: '#{"balance":{"brand":"$0.Alleged: IST brand","value":"+500000000"},"exitAfterBuy":false,"price":{"denominator":{"brand":"$1.Alleged: ATOM brand","value":"+10"},"numerator":{"brand":"$0","value":"+95"}},"sequence":"+1001","timestamp":{"absValue":"+1689097758","timerBrand":"$2.Alleged: timerBrand"},"wanted":{"brand":"$1","value":"+1000000000000"}}', + slots: ['board0257', 'board02152', 'board0425'], + }, + }, + }, + }, + { + id: 1, + jsonrpc: '2.0', + result: { + response: { + code: 0, + codespace: '', + height: '7424', + index: '0', + info: '', + key: null, + log: '', + proofOps: null, + valueBlockHeight: '7414', + valueCapData: { + body: '#{"balance":{"brand":"$0.Alleged: IST brand","value":"+300000000"},"exitAfterBuy":false,"price":{"denominator":{"brand":"$1.Alleged: ATOM brand","value":"+10"},"numerator":{"brand":"$0","value":"+85"}},"sequence":"+1002","timestamp":{"absValue":"+1689119404","timerBrand":"$2.Alleged: timerBrand"},"wanted":{"brand":"$1","value":"+1000000000000"}}', + slots: ['board0257', 'board02152', 'board0425'], + }, + }, + }, + }, + { + id: 2, + jsonrpc: '2.0', + result: { + response: { + code: 0, + codespace: '', + height: '7424', + index: '0', + info: '', + key: null, + log: '', + proofOps: null, + valueBlockHeight: '7414', + valueCapData: { + body: '#{"balance":{"brand":"$0.Alleged: IST brand","value":"+200000000"},"bidScaling":{"denominator":{"brand":"$0","value":"+100"},"numerator":{"brand":"$0","value":"+90"}},"exitAfterBuy":false,"sequence":"+1003","timestamp":{"absValue":"+1689134368","timerBrand":"$1.Alleged: timerBrand"},"wanted":{"brand":"$2.Alleged: ATOM brand","value":"+1000000000000"}}', + slots: ['board0257', 'board0425', 'board02152'], + }, + }, + }, + }, + ], + }), +); diff --git a/packages/inter-cli/test/snapshots/test-auction.js.md b/packages/inter-cli/test/snapshots/test-auction.js.md new file mode 100644 index 00000000000..0a2d2a9e95c --- /dev/null +++ b/packages/inter-cli/test/snapshots/test-auction.js.md @@ -0,0 +1,45 @@ +# Snapshot report for `test/test-auction.js` + +The actual snapshot is saved in `test-auction.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## FR5: Usage: inter-tool --help + +> Command usage: + + `Usage: inter-tool [options] [command]␊ + ␊ + Options:␊ + -h, --help display help for command␊ + ␊ + Commands:␊ + bid Add a Bid command/operation␊ + help [command] display help for command` + +## FR5: Usage: inter-tool bid --help + +> Command usage: + + `Usage: inter-tool bid [options] [command]␊ + ␊ + Add a Bid command/operation␊ + ␊ + Options:␊ + -h, --help display help for command␊ + ␊ + Commands:␊ + list [options] List Bids operation␊ + help [command] display help for command` + +## FR5: Usage: inter-tool bid list --help + +> Command usage: + + `Usage: inter-tool bid list [options]␊ + ␊ + List Bids operation␊ + ␊ + Options:␊ + --book auction book number␊ + -h, --help display help for command` diff --git a/packages/inter-cli/test/snapshots/test-auction.js.snap b/packages/inter-cli/test/snapshots/test-auction.js.snap new file mode 100644 index 00000000000..a4374464ef1 Binary files /dev/null and b/packages/inter-cli/test/snapshots/test-auction.js.snap differ diff --git a/packages/inter-cli/test/test-auction.js b/packages/inter-cli/test/test-auction.js new file mode 100644 index 00000000000..27a91e785ec --- /dev/null +++ b/packages/inter-cli/test/test-auction.js @@ -0,0 +1,211 @@ +// @ts-check +/* global globalThis, Buffer */ + +import '@endo/init'; + +import anyTest from 'ava'; + +import { createCommand } from 'commander'; +import { makeHttpClient } from '@agoric/casting/src/makeHttpClient.js'; +import { + captureIO, + replayIO, +} from '@agoric/casting/test/net-access-fixture.js'; + +import { QueryDataResponse } from '@agoric/cosmic-proto/vstorage/query.js'; +import { toBase64 } from '@cosmjs/encoding'; +import { addBidCommand } from '../src/commands/auction.js'; +import { listBidsRPC } from './rpc-fixture.js'; +import { extractCapData } from '../src/lib/boardClient.js'; +import { makeBatchQuery } from '../src/lib/vstorage.js'; + +/** @type {import('ava').TestFn>>} */ +const test = /** @type {any} */ (anyTest); + +const RECORDING = false; + +const makeTestContext = async () => { + return { fetch: globalThis.fetch, recording: RECORDING }; +}; + +test.before(async t => { + t.context = await makeTestContext(); +}); + +/** @typedef {import('commander').Command} Command */ + +/** @type {(c: Command) => Command[]} */ +const subCommands = c => [c, ...c.commands.flatMap(subCommands)]; + +const usageTest = (words, blurb = 'Command usage:') => { + test(`FR5: Usage: ${words}`, async t => { + const argv = `node ${words} --help`.split(' '); + + const out = []; + const tui = { + show: (info, _pretty) => { + out.push(info); + }, + warn: () => {}, + }; + const program = createCommand('inter-tool'); + const { context: io } = t; + const rpcClient = makeHttpClient('', io.fetch); + const cmd = addBidCommand(program, { + tui, + getBatchQuery: async () => makeBatchQuery(io.fetch, []), + makeRpcClient: async () => rpcClient, + }); + for (const c of subCommands(program)) { + c.exitOverride(); + } + cmd.configureOutput({ + writeOut: s => out.push(s), + writeErr: s => out.push(s), + }); + + await t.throwsAsync(program.parseAsync(argv), { message: /outputHelp/ }); + t.snapshot(out.join('').trim(), blurb); + }); +}; +usageTest('inter-tool --help'); +usageTest('inter-tool bid --help'); +usageTest('inter-tool bid list --help'); + +test.todo('FR5: --asset'); + +/** + * Decode JSON RPC response value from base64 etc. + * + * @param {string} req + * @param {*} x + */ +const responseDetail = (req, x) => { + const { + result: { + response: { value }, + }, + } = x; + const decoded = Buffer.from(value, 'base64').toString(); + if (req.includes('Query/Children')) { + // don't mess with protobuf-encoded list of children + return { value }; + } + // quick-n-dirty protobuf decoding for a single string message + const json = decoded.replace(/^[^{]*/, ''); + const resp = QueryDataResponse.fromJSON(JSON.parse(json)); + const { blockHeight } = JSON.parse(resp.value); + const valueCapData = extractCapData(resp); + return { valueCapData, valueBlockHeight: blockHeight }; +}; + +/** + * Reverse the effect of responseDetail for all items in a map. + * + * @param {Map} webMap + */ +const encodeDetail = webMap => { + const encode1 = (x, req) => { + const { + result: { response }, + } = x; + if ('value' in response) { + return x; + } + if ('valueCapData' in response) { + const blockHeight = response.valueBlockHeight; + const cellValue = JSON.stringify(response.valueCapData); + const value = JSON.stringify({ blockHeight, values: [cellValue] }); + response.value = toBase64(Buffer.from(JSON.stringify({ value }))); + delete response.valueCapData; + delete response.valueBlockHeight; + return x; + } + console.warn(`Unknown response`, req); + return x; + }; + const out = new Map(); + for (const [req, x] of webMap) { + out.set(req, Array.isArray(x) ? x.map(encode1) : encode1(x, req)); + } + return out; +}; + +test('see all bids for a collateral type', async t => { + const args = 'node inter-tool bid list'; + t.log(args.replace(/^node /, '')); + const expected = [ + { + timestamp: '2023-07-11T17:49:18Z', + sequence: 1001, + price: '9.5000 IST/ATOM', + balance: '500 IST', + wanted: '1000000 ATOM', + }, + { + timestamp: '2023-07-11T23:50:04Z', + sequence: 1002, + price: '8.5000 IST/ATOM', + balance: '300 IST', + wanted: '1000000 ATOM', + }, + { + timestamp: '2023-07-12T03:59:28Z', + sequence: 1003, + bidScaling: '90.0000%', + balance: '200 IST', + wanted: '1000000 ATOM', + }, + ]; + + const out = []; + const tui = { + show: (info, _pretty) => { + t.log(JSON.stringify(info)); + out.push(info); + }, + warn: () => {}, + }; + + const io = t.context; + const config = { + rpcAddrs: ['http://0.0.0.0:26657'], + chainName: 'agoriclocal', + }; + const { fetch: fetchMock, web } = io.recording + ? captureIO(io.fetch) + : { fetch: replayIO(encodeDetail(listBidsRPC)), web: new Map() }; + const rpcClient = makeHttpClient(config.rpcAddrs[0], fetchMock); + + const prog = createCommand('inter'); + addBidCommand(prog, { + tui, + getBatchQuery: async () => makeBatchQuery(fetchMock, config.rpcAddrs), + makeRpcClient: async () => rpcClient, + }); + await prog.parseAsync(args.split(' ')); + + if (io.recording) { + // use responseDetail() to make a more clear snapshot / fixture + const decoded = new Map(); + for (const [req, resp] of web) { + const xs = Array.isArray(resp) ? resp : [resp]; + for (const x of xs) { + const detail = responseDetail(req, x); + delete x.result.response.value; + Object.assign(x.result.response, detail); + } + decoded.set(req, resp); + } + t.snapshot(decoded); + } + t.deepEqual(out, expected); +}); + +test.todo('should timestamps really not show timezone?'); +test.todo('FR3: show partially filled bids'); +test.todo('FR5: --from-bidder, --from-everyone'); +test.todo('want should be maxBuy'); +test.todo('balance should be give'); +test.todo('give should be formatted as keyword record with Bid keyword'); +test.todo('price should be a number with implicit brands (?)'); diff --git a/packages/inter-cli/test/test-boardClient.js b/packages/inter-cli/test/test-boardClient.js new file mode 100644 index 00000000000..906ff2cebb6 --- /dev/null +++ b/packages/inter-cli/test/test-boardClient.js @@ -0,0 +1,58 @@ +// @ts-check +import '@endo/init'; +import { M, matches, keyEQ } from '@endo/patterns'; +import { makeMarshal } from '@endo/marshal'; + +import test from 'ava'; +import { fc } from '@fast-check/ava'; + +import { makeBoardContext } from '../src/lib/boardClient.js'; +import { arbKey } from './arbPassableKey.js'; + +const arbBoardId = fc.integer().map(n => `board0${n}`); + +const arbSlot = arbBoardId.chain(slot => + fc.string({ minLength: 1 }).map(iface => ({ slot, iface })), +); + +const RemotableShape = M.remotable(); + +test('boardProxy.provide() preserves identity', t => { + const bp = makeBoardContext(); + fc.assert( + fc.property( + fc.record({ s1: arbSlot, s3: arbSlot, die: fc.nat(2) }), + ({ s1, s3, die }) => { + const v1 = bp.register(s1.slot, s1.iface); + const { slot, iface = undefined } = die > 0 ? s3 : { slot: s1.slot }; + const v2 = bp.register(slot, iface); + t.is(s1.slot === slot, v1 === v2); + t.true(matches(v1, RemotableShape)); + t.true(matches(v2, RemotableShape)); + }, + ), + ); +}); + +test('boardCtx ingest() preserves identity for passable keys', t => { + const ctx = makeBoardContext(); + const valToSlot = new Map(); + const m = makeMarshal( + v => { + if (valToSlot.has(v)) return valToSlot.get(v); + const slot = `board0${valToSlot.size}`; + valToSlot.set(v, slot); + return slot; + }, + undefined, + { serializeBodyFormat: 'smallcaps' }, + ); + fc.assert( + fc.property(arbKey, key => { + const { body, slots } = m.toCapData(key); + const ingested = ctx.ingest({ body, slots }); + const reingested = ctx.ingest({ body, slots }); + t.true(keyEQ(ingested, reingested)); + }), + ); +}); diff --git a/packages/inter-cli/test/test-format.js b/packages/inter-cli/test/test-format.js new file mode 100644 index 00000000000..6da7665510c --- /dev/null +++ b/packages/inter-cli/test/test-format.js @@ -0,0 +1,128 @@ +// @ts-check +import '@endo/init'; +import { Far } from '@endo/far'; + +import test from 'ava'; +import { testProp, fc } from '@fast-check/ava'; +import { makeAssetFormatters } from '../src/lib/format.js'; + +const { values, fromEntries } = Object; + +/** @type {Record} */ +const someTokens = Object.fromEntries( + [ + { denom: 'ubld', name: 'BLD', decimals: 6 }, + { denom: 'uist', name: 'IST', decimals: 6 }, + { denom: 'ibc/toyatom', name: 'ATOM', decimals: 6 }, + { denom: 'ibc/toystar', name: 'STAR', decimals: 6 }, + { denom: 'ibc/toydaiwei', name: 'DAI', decimals: 18 }, + ].map(info => [ + info.name, + { + denom: info.denom, + issuerName: info.name, + proposedName: info.name, + displayInfo: { assetKind: 'nat', decimalPlaces: info.decimals }, + brand: /** @type {Brand<'nat'>} */ (Far(`${info.name} Brand`)), + issuer: /** @type {Issuer<'nat'>} */ (Far(`${info.name} Issuer`)), + }, + ]), +); + +const optToken = fc.constantFrom(...Object.values(someTokens), undefined); + +/** @type {import('fast-check').Arbitrary} */ +const arbNatDisplayInfo = fc.record({ + assetKind: fc.constant('nat'), + decimalPlaces: fc.oneof(fc.nat({ max: 24 }), fc.constant(undefined)), +}); + +const arbKeyword = fc + .string({ maxLength: 32 }) + .filter(s => /^[A-Z][a-zA-Z0-9_]+$/.test(s)); + +/** @type {import('fast-check').Arbitrary>} */ +const arbNatBrand = arbKeyword.map(kw => Far(`${kw} Brand`)); + +/** @type {import('fast-check').Arbitrary>} */ +const arbNatIssuer = arbKeyword.map(kw => Far(`${kw} Issuer`)); + +/** @type {import('fast-check').Arbitrary} */ +const arbAsset = optToken.chain(tok => + fc.record({ + denom: tok ? fc.constant(tok.denom) : fc.string(), + brand: tok ? fc.constant(tok.brand) : arbNatBrand, + issuer: tok ? fc.constant(tok.issuer) : arbNatIssuer, + displayInfo: tok ? fc.constant(tok.displayInfo) : arbNatDisplayInfo, + issuerName: tok ? fc.constant(tok.issuerName) : arbKeyword, + proposedName: tok + ? fc.constant(tok.proposedName) + : fc.string({ maxLength: 100 }), + }), +); + +const arbAssets = fc + .array(arbAsset, { minLength: 2 }) + .map(assets => fromEntries(assets.map(a => [a.issuerName, a]))); + +testProp( + 'makeAssetFormatters handles all nat amounts, ratios', + // @ts-expect-error something odd about fc.ArbitraryTuple + [ + fc.record({ + assets: arbAssets, + nb: fc.nat(), + db: fc.nat(), + nv: fc.nat().map(BigInt), + dv: fc + .nat() + .filter(n => n > 0) + .map(BigInt), + }), + ], + (t, { assets, nb, db, nv, dv }) => { + const fmt = makeAssetFormatters(assets); + + const pickBrand = n => values(assets)[n % values(assets).length].brand; + const ratio = { + numerator: { brand: pickBrand(nb), value: nv }, + denominator: { brand: pickBrand(db), value: dv }, + }; + + const top = fmt.amount(ratio.numerator); + t.regex(top, /^[0-9]+(\.[0-9]+)? \w+$/); + + fc.pre(fmt.hasBrand(ratio.denominator.brand)); + const bot = fmt.amount(ratio.denominator); + const price = fmt.price(ratio); + const rate = + ratio.numerator.brand === ratio.denominator.brand + ? fmt.rate(ratio) + : 'N/A'; + 'skip this log' || + t.log({ + _1_top: top, + _2_bot: bot, + price, + rate, + assets: Object.keys(assets), + }); + if (/e[+-]/.test(price)) { + return; + } + t.regex(price, /^[0-9]+(\.[0-9]+)?( \w+\/\w+)?$/); + }, +); + +test.failing('format price > 1e20', t => { + const fmt = makeAssetFormatters(someTokens); + const { DAI, BLD } = someTokens; + const ratio = { + numerator: { brand: DAI.brand, value: 1000000000n }, + denominator: { brand: BLD.brand, value: 1n }, + }; + const price = fmt.price(ratio); + t.regex(price, /^[0-9]+(\.[0-9]+)?( \w+\/\w+)?$/); +}); + +test.todo('prices truncate at 4 decimal places'); diff --git a/packages/inter-cli/test/test-networkConfig.js b/packages/inter-cli/test/test-networkConfig.js new file mode 100644 index 00000000000..496d9c1e743 --- /dev/null +++ b/packages/inter-cli/test/test-networkConfig.js @@ -0,0 +1,28 @@ +import '@endo/init'; + +import test from 'ava'; +import { getNetworkConfig } from '../src/lib/networkConfig.js'; + +test('support AGORIC_NET', async t => { + const env = { AGORIC_NET: 'devnet' }; + const config = { + chainName: 'agoricdev-20', + gci: 'https://devnet.rpc.agoric.net:443/genesis', + peers: ['fb86a0993c694c981a28fa1ebd1fd692f345348b@34.30.39.238:26656'], + rpcAddrs: ['https://devnet.rpc.agoric.net:443'], + apiAddrs: ['https://devnet.api.agoric.net:443'], + seeds: ['0f04c4610b7511a64b8644944b907416db568590@104.154.56.194:26656'], + }; + const fetched = []; + /** @type {typeof window.fetch} */ + // @ts-expect-error mock + const fetch = async url => { + fetched.push(url); + return { + json: async () => config, + }; + }; + const actual = await getNetworkConfig(env, { fetch }); + t.deepEqual(actual, config); + t.deepEqual(fetched, ['https://devnet.agoric.net/network-config']); +}); diff --git a/packages/inter-cli/test/test-vstorage.js b/packages/inter-cli/test/test-vstorage.js new file mode 100644 index 00000000000..422d16e94ff --- /dev/null +++ b/packages/inter-cli/test/test-vstorage.js @@ -0,0 +1,37 @@ +// @ts-check +import '@endo/init'; +import test from 'ava'; +import { fc } from '@fast-check/ava'; +import { extractStreamCellValue } from '../src/lib/vstorage.js'; + +const arbStreamCell = fc.record({ + blockHeight: fc.nat().map(n => `${n}`), + values: fc.array(fc.string(), { minLength: 1 }), +}); + +const noDataAtPath = fc.constant({ value: '' }); +const arbQueryDataResponse = fc.oneof( + noDataAtPath, + arbStreamCell.map(cell => ({ + value: JSON.stringify(cell), + })), +); + +test('extractStreamCellValue() handles empty response', t => { + const resp = { value: '' }; + t.throws(() => extractStreamCellValue(resp), { + message: /no StreamCell values/, + }); +}); + +test('extractStreamCellValue() handles all valid non-empty responses', t => { + fc.assert( + fc.property( + arbQueryDataResponse.filter(resp => resp.value !== ''), + resp => { + const actual = extractStreamCellValue(resp); + t.is(typeof actual, 'string'); + }, + ), + ); +}); diff --git a/packages/inter-protocol/package.json b/packages/inter-protocol/package.json index d701ee4a308..88daacbb07d 100644 --- a/packages/inter-protocol/package.json +++ b/packages/inter-protocol/package.json @@ -44,6 +44,7 @@ "@endo/far": "^0.2.18", "@endo/marshal": "^0.8.5", "@endo/nat": "^4.1.27", + "@endo/promise-kit": "^0.2.56", "jessie.js": "^0.3.2" }, "devDependencies": { diff --git a/packages/inter-protocol/src/auction/auctionBook.js b/packages/inter-protocol/src/auction/auctionBook.js index c72ee7bac0a..437fbd82923 100644 --- a/packages/inter-protocol/src/auction/auctionBook.js +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -4,7 +4,11 @@ import '@agoric/zoe/src/contracts/exported.js'; import { AmountMath } from '@agoric/ertp'; import { mustMatch } from '@agoric/store'; -import { M, prepareExoClassKit } from '@agoric/vat-data'; +import { + M, + prepareExoClassKit, + provideDurableMapStore, +} from '@agoric/vat-data'; import { assertAllDefined, makeTracer } from '@agoric/internal'; import { @@ -68,8 +72,8 @@ const trace = makeTracer('AucBook', true); * @param {Brand<'nat'>} collateralBrand */ export const makeOfferSpecShape = (bidBrand, collateralBrand) => { - const bidAmountShape = makeNatAmountShape(bidBrand); - const collateralAmountShape = makeNatAmountShape(collateralBrand); + const bidAmountShape = makeNatAmountShape(bidBrand, 0n); + const collateralAmountShape = makeNatAmountShape(collateralBrand, 0n); return M.splitRecord( { maxBuy: collateralAmountShape }, { @@ -101,14 +105,44 @@ export const makeOfferSpecShape = (bidBrand, collateralBrand) => { * @property {Amount<'nat'> | null} collateralAvailable The amount of collateral remaining */ +/** + * @typedef {object} ScaledBidData + * + * @property {Amount<'nat'>} balance + * @property {Ratio} bidScaling + * @property {NatValue} sequence + * @property {Timestamp} timestamp + * @property {Amount<'nat'>} wanted + * @property {boolean} exitAfterBuy + */ + +/** + * @typedef {object} PricedBidData + * + * @property {Amount<'nat'>} balance + * @property {Ratio} price + * @property {NatValue} sequence + * @property {Timestamp} timestamp + * @property {Amount<'nat'>} wanted + * @property {boolean} exitAfterBuy + */ + +/** + * @typedef {object} BidDataNotification + * + * @property {Array} scaledBids + * @property {Array} pricedBids + */ + /** * @param {Baggage} baggage * @param {ZCF} zcf * @param {import('@agoric/zoe/src/contractSupport/recorder.js').MakeRecorderKit} makeRecorderKit */ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { - const makeScaledBidBook = prepareScaledBidBook(baggage); - const makePriceBook = preparePriceBook(baggage); + const bidDataKits = provideDurableMapStore(baggage, 'bidDataKits'); + const makeScaledBidBook = prepareScaledBidBook(baggage, makeRecorderKit); + const makePriceBook = preparePriceBook(baggage, makeRecorderKit); const AuctionBookStateShape = harden({ collateralBrand: M.any(), @@ -120,6 +154,8 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { priceAuthority: M.any(), updatingOracleQuote: M.any(), bookDataKit: M.any(), + bidDataKits: M.any(), + bidsDataKit: M.any(), priceBook: M.any(), scaledBidBook: M.any(), startCollateral: M.any(), @@ -137,9 +173,9 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { * @param {Brand<'nat'>} bidBrand * @param {Brand<'nat'>} collateralBrand * @param {PriceAuthority} pAuthority - * @param {StorageNode} node + * @param {Array} nodes */ - (bidBrand, collateralBrand, pAuthority, node) => { + (bidBrand, collateralBrand, pAuthority, nodes) => { assertAllDefined({ bidBrand, collateralBrand, pAuthority }); const zeroBid = makeEmpty(bidBrand); const zeroRatio = makeRatioFromAmounts( @@ -153,25 +189,35 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { // returned to the funders. const { zcfSeat: collateralSeat } = zcf.makeEmptySeatKit(); const { zcfSeat: bidHoldingSeat } = zcf.makeEmptySeatKit(); + const [scheduleNode, bidsNode] = nodes; + + const bidAmountShape = makeNatAmountShape(bidBrand, 0n); + const collateralAmountShape = makeNatAmountShape(collateralBrand, 0n); - const bidAmountShape = makeNatAmountShape(bidBrand); - const collateralAmountShape = makeNatAmountShape(collateralBrand); const scaledBidBook = makeScaledBidBook( makeBrandedRatioPattern(bidAmountShape, bidAmountShape), collateralBrand, + bidsNode, ); const priceBook = makePriceBook( makeBrandedRatioPattern(bidAmountShape, collateralAmountShape), collateralBrand, + bidsNode, ); const bookDataKit = makeRecorderKit( - node, + scheduleNode, /** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedMatcher} */ ( M.any() ), ); + const bidsDataKit = makeRecorderKit( + bidsNode, + /** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedMatcher} */ ( + M.any() + ), + ); return { collateralBrand, @@ -185,6 +231,8 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { updatingOracleQuote: zeroRatio, bookDataKit, + bidDataKits, + bidsDataKit, priceBook, scaledBidBook, @@ -347,6 +395,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { * @param {ZCFSeat} seat * @param {Ratio} price * @param {Amount<'nat'>} maxBuy + * @param {Timestamp} timestamp * @param {object} opts * @param {boolean} opts.trySettle * @param {boolean} [opts.exitAfterBuy] @@ -355,6 +404,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { seat, price, maxBuy, + timestamp, { trySettle, exitAfterBuy = false }, ) { const { priceBook, curAuctionPrice } = this.state; @@ -381,7 +431,8 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { seat.exit(); } else { trace('added Offer ', price, stillWant.value); - priceBook.add(seat, price, stillWant, exitAfterBuy); + priceBook.add(seat, price, stillWant, exitAfterBuy, timestamp); + helper.publishBidData(); } void helper.publishBookData(); @@ -395,6 +446,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { * @param {ZCFSeat} seat * @param {Ratio} bidScaling * @param {Amount<'nat'>} maxBuy + * @param {Timestamp} timestamp * @param {object} opts * @param {boolean} opts.trySettle * @param {boolean} [opts.exitAfterBuy] @@ -403,6 +455,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { seat, bidScaling, maxBuy, + timestamp, { trySettle, exitAfterBuy = false }, ) { trace(this.state.collateralBrand, 'accept scaledBid offer'); @@ -435,11 +488,23 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { ) { seat.exit(); } else { - scaledBidBook.add(seat, bidScaling, stillWant, exitAfterBuy); + scaledBidBook.add( + seat, + bidScaling, + stillWant, + exitAfterBuy, + timestamp, + ); + void helper.publishBidData(); } void helper.publishBookData(); }, + publishBidData() { + const { state } = this; + state.scaledBidBook.publishOffers(); + state.priceBook.publishOffers(); + }, publishBookData() { const { state } = this; @@ -458,6 +523,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { collateralAvailable, currentPriceLevel: state.curAuctionPrice, }); + return state.bookDataKit.recorder.write(bookData); }, }, @@ -607,6 +673,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { } void facets.helper.publishBookData(); + void facets.helper.publishBidData(); }, getCurrentPrice() { return this.state.curAuctionPrice; @@ -644,8 +711,9 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { * @param {OfferSpec} offerSpec * @param {ZCFSeat} seat * @param {boolean} trySettle + * @param {Timestamp} timestamp */ - addOffer(offerSpec, seat, trySettle) { + addOffer(offerSpec, seat, trySettle, timestamp) { const { bidBrand, collateralBrand } = this.state; const offerSpecShape = makeOfferSpecShape(bidBrand, collateralBrand); @@ -661,6 +729,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { seat, offerSpec.offerPrice, offerSpec.maxBuy, + timestamp, { trySettle, exitAfterBuy, @@ -671,6 +740,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { seat, offerSpec.offerBidScaling, offerSpec.maxBuy, + timestamp, { trySettle, exitAfterBuy, @@ -710,6 +780,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { 'Auction schedule', this.state.bookDataKit, ), + bids: makeRecorderTopic('Auction Bids', this.state.bidsDataKit), }; }, }, @@ -749,6 +820,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { }, ); void facets.helper.publishBookData(); + void facets.helper.publishBidData(); }, stateShape: AuctionBookStateShape, }, diff --git a/packages/inter-protocol/src/auction/auctioneer.js b/packages/inter-protocol/src/auction/auctioneer.js index 8469ee52107..6abdaf5da4c 100644 --- a/packages/inter-protocol/src/auction/auctioneer.js +++ b/packages/inter-protocol/src/auction/auctioneer.js @@ -624,11 +624,12 @@ export const start = async (zcf, privateArgs, baggage) => { * @param {ZCFSeat} zcfSeat * @param {import('./auctionBook.js').OfferSpec} offerSpec */ - const newBidHandler = (zcfSeat, offerSpec) => { + const newBidHandler = async (zcfSeat, offerSpec) => { // xxx consider having Zoe guard the offerArgs with a provided shape mustMatch(offerSpec, offerSpecShape); const auctionBook = books.get(collateralBrand); - auctionBook.addOffer(offerSpec, zcfSeat, isActive()); + const timestamp = await E(timer).getCurrentTimestamp(); + auctionBook.addOffer(offerSpec, zcfSeat, isActive(), timestamp); return 'Your bid has been accepted'; }; @@ -689,13 +690,19 @@ export const start = async (zcf, privateArgs, baggage) => { const bookId = `book${bookCounter}`; bookCounter += 1; - const bNode = await E(privateArgs.storageNode).makeChildNode(bookId); + + const bookNodeP = E(privateArgs.storageNode).makeChildNode(bookId); + const [scheduleNode, bidsNode] = await Promise.all([ + bookNodeP, + E(bookNodeP).makeChildNode('schedule'), + E(bookNodeP).makeChildNode('bids'), + ]); const newBook = await makeAuctionBook( brands.Bid, brand, priceAuthority, - bNode, + [scheduleNode, bidsNode], ); // These three store.init() calls succeed or fail atomically diff --git a/packages/inter-protocol/src/auction/offerBook.js b/packages/inter-protocol/src/auction/offerBook.js index 2f8adb60075..75d4abf90f1 100644 --- a/packages/inter-protocol/src/auction/offerBook.js +++ b/packages/inter-protocol/src/auction/offerBook.js @@ -1,9 +1,17 @@ // book of offers to buy liquidating vaults with prices in terms of // discount/markup from the current oracle price. -import { AmountMath } from '@agoric/ertp'; +import { E } from '@endo/captp'; +import { AmountMath, BrandShape } from '@agoric/ertp'; +import { StorageNodeShape } from '@agoric/internal'; import { M, mustMatch } from '@agoric/store'; -import { makeScalarBigMapStore, prepareExoClass } from '@agoric/vat-data'; +import { + makeScalarBigMapStore, + makeScalarMapStore, + prepareExoClass, + provide, +} from '@agoric/vat-data'; +import { makePromiseKit } from '@endo/promise-kit'; import { toBidScalingComparator, @@ -14,12 +22,11 @@ import { /** @typedef {import('@agoric/vat-data').Baggage} Baggage */ -// multiple offers might be provided at the same time (since the time -// granularity is limited to blocks), so we increment a sequenceNumber with each -// offer for uniqueness. -let latestSequenceNumber = 0n; -const nextSequenceNumber = () => { +/** @type {(baggage: Baggage) => bigint} */ +const nextSequenceNumber = baggage => { + let latestSequenceNumber = provide(baggage, 'sequenceNumber', () => 1000n); latestSequenceNumber += 1n; + baggage.set('sequenceNumber', latestSequenceNumber); return latestSequenceNumber; }; @@ -29,37 +36,67 @@ const nextSequenceNumber = () => { * wanted: Amount<'nat'>, * seqNum: NatValue, * received: Amount<'nat'>, + * timestamp: Timestamp, * } & {exitAfterBuy: boolean} & ({ bidScaling: Pattern, price: undefined } | { bidScaling: undefined, price: Ratio}) * } BidderRecord */ const ScaledBidBookStateShape = harden({ bidScalingPattern: M.any(), - collateralBrand: M.any(), + collateralBrand: BrandShape, records: M.any(), + bidsNode: StorageNodeShape, }); +const makeBidNode = (bidsNode, bidId) => + E(bidsNode).makeChildNode(`bid${bidId}`); + +const makeGetBidDataRecorder = (bidDataKits, bidDataKitPromises) => { + return key => { + if (bidDataKitPromises.has(key)) { + return E.get(bidDataKitPromises.get(key)).recorder; + } + return bidDataKits.get(key).recorder; + }; +}; + +/** @typedef {ReturnType} RecorderKit */ + /** * Prices in this book are expressed as percentage of the full oracle price * snapshot taken when the auction started. .4 is 60% off. 1.1 is 10% above par. * * @param {Baggage} baggage + * @param {import('@agoric/zoe/src/contractSupport/recorder.js').MakeRecorderKit} makeRecorderKit */ -export const prepareScaledBidBook = baggage => - prepareExoClass( +export const prepareScaledBidBook = (baggage, makeRecorderKit) => { + // multiple offers might be provided at the same timestamp (since timestamp + // granularity is limited to blocks), so we increment a sequenceNumber with + // each offer for uniqueness. + + const bidDataKits = baggage.get('bidDataKits'); + /** @type {MapStore>} */ + const bidDataKitPromises = makeScalarMapStore('bidDataKit Promises'); + const getBidDataRecorder = makeGetBidDataRecorder( + bidDataKits, + bidDataKitPromises, + ); + + return prepareExoClass( baggage, 'scaledBidBook', undefined, /** - * * @param {Pattern} bidScalingPattern * @param {Brand} collateralBrand + * @param {StorageNode} bidsNode */ - (bidScalingPattern, collateralBrand) => ({ + (bidScalingPattern, collateralBrand, bidsNode) => ({ bidScalingPattern, collateralBrand, /** @type {MapStore} */ records: makeScalarBigMapStore('scaledBidRecords', { durable: true }), + bidsNode, }), { /** @@ -67,23 +104,37 @@ export const prepareScaledBidBook = baggage => * @param {Ratio} bidScaling * @param {Amount<'nat'>} wanted * @param {boolean} exitAfterBuy + * @param {Timestamp} timestamp */ - add(seat, bidScaling, wanted, exitAfterBuy) { - const { bidScalingPattern, collateralBrand, records } = this.state; + add(seat, bidScaling, wanted, exitAfterBuy, timestamp) { + const { bidScalingPattern, collateralBrand, records, bidsNode } = + this.state; mustMatch(bidScaling, bidScalingPattern); - const seqNum = nextSequenceNumber(); + const seqNum = nextSequenceNumber(baggage); const key = toScaledRateOfferKey(bidScaling, seqNum); - const empty = AmountMath.makeEmpty(collateralBrand); + + /** @type {PromiseKit} */ + const bidDataKitP = makePromiseKit(); + bidDataKitPromises.init(key, bidDataKitP.promise); + E.when(makeBidNode(bidsNode, seqNum), childBidNode => { + const recorderKit = makeRecorderKit(childBidNode); + bidDataKits.init(key, recorderKit); + bidDataKitP.resolve(recorderKit); + bidDataKitPromises.delete(key); + return recorderKit; + }); + /** @type {BidderRecord} */ const bidderRecord = { bidScaling, price: undefined, - received: empty, + received: AmountMath.makeEmpty(collateralBrand), seat, seqNum, wanted, exitAfterBuy, + timestamp, }; records.init(key, harden(bidderRecord)); return key; @@ -93,30 +144,54 @@ export const prepareScaledBidBook = baggage => const { records } = this.state; return [...records.entries(M.gte(toBidScalingComparator(bidScaling)))]; }, + publishOffer(record) { + const key = toScaledRateOfferKey(record.bidScaling, record.seqNum); + + return E(getBidDataRecorder(key)).write( + harden({ + bidScaling: record.bidScaling, + wanted: record.wanted, + exitAfterBuy: record.exitAfterBuy, + timestamp: record.timestamp, + balance: record.seat.getCurrentAllocation().Bid, + sequence: record.seqNum, + }), + ); + }, + publishOffers() { + const { records } = this.state; + + for (const r of records.values()) { + this.self.publishOffer(r); + } + }, hasOrders() { const { records } = this.state; return records.getSize() > 0; }, delete(key) { const { records } = this.state; + bidDataKits.delete(key); records.delete(key); }, updateReceived(key, sold) { const { records } = this.state; const oldRec = records.get(key); - records.set( - key, - harden({ - ...oldRec, - received: AmountMath.add(oldRec.received, sold), - }), - ); + const newRecord = harden({ + ...oldRec, + received: AmountMath.add(oldRec.received, sold), + }); + records.set(key, newRecord); + this.self.publishOffer(newRecord); }, exitAllSeats() { const { records } = this.state; for (const [key, { seat }] of records.entries()) { if (!seat.hasExited()) { seat.exit(); + if (bidDataKits.has(key)) { + bidDataKits.delete(key); + } records.delete(key); } } @@ -126,11 +201,13 @@ export const prepareScaledBidBook = baggage => stateShape: ScaledBidBookStateShape, }, ); +}; const PriceBookStateShape = harden({ priceRatioPattern: M.any(), - collateralBrand: M.any(), + collateralBrand: BrandShape, records: M.any(), + bidsNode: StorageNodeShape, }); /** @@ -138,22 +215,32 @@ const PriceBookStateShape = harden({ * and collateral amount. * * @param {Baggage} baggage + * @param {import('@agoric/zoe/src/contractSupport/recorder.js').MakeRecorderKit} makeRecorderKit */ -export const preparePriceBook = baggage => - prepareExoClass( +export const preparePriceBook = (baggage, makeRecorderKit) => { + const bidDataKits = baggage.get('bidDataKits'); + /** @type {MapStore>} */ + const bidDataKitPromises = makeScalarMapStore('bidDataKit Promises'); + const getBidDataRecorder = makeGetBidDataRecorder( + bidDataKits, + bidDataKitPromises, + ); + + return prepareExoClass( baggage, 'priceBook', undefined, /** - * * @param {Pattern} priceRatioPattern * @param {Brand} collateralBrand + * @param {StorageNode} bidsNode */ - (priceRatioPattern, collateralBrand) => ({ + (priceRatioPattern, collateralBrand, bidsNode) => ({ priceRatioPattern, collateralBrand, /** @type {MapStore} */ records: makeScalarBigMapStore('scaledBidRecords', { durable: true }), + bidsNode, }), { /** @@ -161,55 +248,92 @@ export const preparePriceBook = baggage => * @param {Ratio} price * @param {Amount<'nat'>} wanted * @param {boolean} exitAfterBuy + * @param {Timestamp} timestamp */ - add(seat, price, wanted, exitAfterBuy) { - const { priceRatioPattern, collateralBrand, records } = this.state; + add(seat, price, wanted, exitAfterBuy, timestamp) { + const { priceRatioPattern, collateralBrand, records, bidsNode } = + this.state; mustMatch(price, priceRatioPattern); - const seqNum = nextSequenceNumber(); + const seqNum = nextSequenceNumber(baggage); const key = toPriceOfferKey(price, seqNum); - const empty = AmountMath.makeEmpty(collateralBrand); + + /** @type {PromiseKit} */ + const bidDataKitP = makePromiseKit(); + bidDataKitPromises.init(key, bidDataKitP.promise); + E.when(makeBidNode(bidsNode, seqNum), childBidNode => { + const recorderKit = makeRecorderKit(childBidNode); + bidDataKits.init(key, recorderKit); + bidDataKitP.resolve(recorderKit); + bidDataKitPromises.delete(key); + return recorderKit; + }); + /** @type {BidderRecord} */ - const bidderRecord = { + const bidderRecord = harden({ bidScaling: undefined, price, - received: empty, + received: AmountMath.makeEmpty(collateralBrand), seat, seqNum, wanted, exitAfterBuy, - }; - records.init(key, harden(bidderRecord)); + timestamp, + }); + records.init(key, bidderRecord); return key; }, offersAbove(price) { const { records } = this.state; return [...records.entries(M.gte(toPartialOfferKey(price)))]; }, + publishOffer(record) { + const key = toPriceOfferKey(record.price, record.seqNum); + + return E(getBidDataRecorder(key)).write( + harden({ + price: record.price, + wanted: record.wanted, + exitAfterBuy: record.exitAfterBuy, + timestamp: record.timestamp, + balance: record.seat.getCurrentAllocation().Bid, + sequence: record.seqNum, + }), + ); + }, + publishOffers() { + const { records } = this.state; + for (const r of records.values()) { + this.self.publishOffer(r); + } + }, hasOrders() { const { records } = this.state; return records.getSize() > 0; }, delete(key) { const { records } = this.state; + bidDataKits.delete(key); records.delete(key); }, updateReceived(key, sold) { const { records } = this.state; const oldRec = records.get(key); - records.set( - key, - harden({ - ...oldRec, - received: AmountMath.add(oldRec.received, sold), - }), - ); + const newRecord = harden({ + ...oldRec, + received: AmountMath.add(oldRec.received, sold), + }); + records.set(key, newRecord); + this.self.publishOffer(newRecord); }, exitAllSeats() { const { records } = this.state; for (const [key, { seat }] of records.entries()) { if (!seat.hasExited()) { seat.exit(); + if (bidDataKits.has(key)) { + bidDataKits.delete(key); + } records.delete(key); } } @@ -219,3 +343,4 @@ export const preparePriceBook = baggage => stateShape: PriceBookStateShape, }, ); +}; diff --git a/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.md b/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.md index f2aab85afd4..8ffb6ec2928 100644 --- a/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.md +++ b/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.md @@ -48,6 +48,122 @@ Generated by [AVA](https://avajs.dev). startProceedsGoal: null, }, ], + [ + 'published.auction.book0.schedule.bid1001', + { + balance: { + brand: Object @Alleged: Bid brand {}, + value: 134n, + }, + exitAfterBuy: false, + price: { + denominator: { + brand: Object @Alleged: Collateral brand {}, + value: 200n, + }, + numerator: { + brand: Object @Alleged: Bid brand {}, + value: 250n, + }, + }, + sequence: 1001n, + timestamp: { + absValue: 167n, + timerBrand: Object @Alleged: timerBrand {}, + }, + wanted: { + brand: Object @Alleged: Collateral brand {}, + value: 200n, + }, + }, + ], + [ + 'published.auction.book0.schedule.bid1002', + { + balance: { + brand: Object @Alleged: Bid brand {}, + value: 200n, + }, + exitAfterBuy: false, + price: { + denominator: { + brand: Object @Alleged: Collateral brand {}, + value: 250n, + }, + numerator: { + brand: Object @Alleged: Bid brand {}, + value: 200n, + }, + }, + sequence: 1002n, + timestamp: { + absValue: 167n, + timerBrand: Object @Alleged: timerBrand {}, + }, + wanted: { + brand: Object @Alleged: Collateral brand {}, + value: 250n, + }, + }, + ], + [ + 'published.auction.book0.schedule.bid1003', + { + balance: { + brand: Object @Alleged: Bid brand {}, + value: 20n, + }, + bidScaling: { + denominator: { + brand: Object @Alleged: Bid brand {}, + value: 100n, + }, + numerator: { + brand: Object @Alleged: Bid brand {}, + value: 50n, + }, + }, + exitAfterBuy: false, + sequence: 1003n, + timestamp: { + absValue: 170n, + timerBrand: Object @Alleged: timerBrand {}, + }, + wanted: { + brand: Object @Alleged: Collateral brand {}, + value: 200n, + }, + }, + ], + [ + 'published.auction.book0.schedule.bid1004', + { + balance: { + brand: Object @Alleged: Bid brand {}, + value: 40n, + }, + bidScaling: { + denominator: { + brand: Object @Alleged: Bid brand {}, + value: 100n, + }, + numerator: { + brand: Object @Alleged: Bid brand {}, + value: 80n, + }, + }, + exitAfterBuy: false, + sequence: 1004n, + timestamp: { + absValue: 170n, + timerBrand: Object @Alleged: timerBrand {}, + }, + wanted: { + brand: Object @Alleged: Collateral brand {}, + value: 2000n, + }, + }, + ], [ 'published.auction.governance', { diff --git a/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.snap b/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.snap index d8afa212683..23e4e632a1c 100644 Binary files a/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.snap and b/packages/inter-protocol/test/auction/snapshots/test-auctionContract.js.snap differ diff --git a/packages/inter-protocol/test/auction/test-auctionBook.js b/packages/inter-protocol/test/auction/test-auctionBook.js index b288460dc81..afc7d9777fc 100644 --- a/packages/inter-protocol/test/auction/test-auctionBook.js +++ b/packages/inter-protocol/test/auction/test-auctionBook.js @@ -14,8 +14,8 @@ import { makeOffer } from '@agoric/zoe/test/unitTests/makeOffer.js'; import { setup } from '@agoric/zoe/test/unitTests/setupBasicMints.js'; import { setupZCFTest } from '@agoric/zoe/test/unitTests/zcf/setupZcfTest.js'; import { makeManualPriceAuthority } from '@agoric/zoe/tools/manualPriceAuthority.js'; -import { makeMockChainStorageRoot } from '../supports.js'; +import { makeMockChainStorageRoot } from '../supports.js'; import { prepareAuctionBook } from '../../src/auction/auctionBook.js'; const buildManualPriceAuthority = initialPrice => @@ -69,12 +69,10 @@ const assembleAuctionBook = async basics => { const makeAuctionBook = prepareAuctionBook(baggage, zcf, makeRecorderKit); const mockChainStorage = makeMockChainStorageRoot(); - const book = await makeAuctionBook( - moolaKit.brand, - simoleanKit.brand, - pa, - mockChainStorage.makeChildNode('thisBook'), - ); + const book = await makeAuctionBook(moolaKit.brand, simoleanKit.brand, pa, [ + mockChainStorage.makeChildNode('schedule'), + mockChainStorage.makeChildNode('bids'), + ]); return { pa, book }; }; @@ -132,13 +130,15 @@ test('simple addOffer', async t => { book.captureOraclePriceForRound(); book.setStartingRate(makeRatio(50n, moolaKit.brand, 100n)); + const tenFor100 = makeRatioFromAmounts(moola(10n), simoleans(100n)); book.addOffer( harden({ - offerPrice: makeRatioFromAmounts(moola(10n), simoleans(100n)), + offerPrice: tenFor100, maxBuy: simoleans(50n), }), zcfSeat, true, + 0n, ); t.true(book.hasOrders()); @@ -174,13 +174,15 @@ test('getOffers to a price limit', async t => { book.captureOraclePriceForRound(); book.setStartingRate(makeRatio(50n, moolaKit.brand, 100n)); + const tenPercent = makeRatioFromAmounts(moola(10n), moola(100n)); book.addOffer( harden({ - offerBidScaling: makeRatioFromAmounts(moola(10n), moola(100n)), + offerBidScaling: tenPercent, maxBuy: simoleans(50n), }), zcfSeat, true, + 0n, ); t.true(book.hasOrders()); @@ -226,6 +228,7 @@ test('Bad keyword', async t => { }), zcfSeat, true, + 0n, ), { message: /give must include "Bid".*/ }, ); @@ -259,13 +262,15 @@ test('getOffers w/discount', async t => { moolaKit, ); + const tenPercent = makeRatioFromAmounts(moola(10n), moola(100n)); book.addOffer( harden({ - offerBidScaling: makeRatioFromAmounts(moola(10n), moola(100n)), + offerBidScaling: tenPercent, maxBuy: simoleans(50n), }), zcfSeat, true, + 0n, ); t.true(book.hasOrders()); diff --git a/packages/inter-protocol/test/auction/test-auctionContract.js b/packages/inter-protocol/test/auction/test-auctionContract.js index 6c6cde42d83..53bf706b24a 100644 --- a/packages/inter-protocol/test/auction/test-auctionContract.js +++ b/packages/inter-protocol/test/auction/test-auctionContract.js @@ -315,7 +315,11 @@ const makeAuctionDriver = async (t, params = defaultParams) => { subscriptionTracker(t, subscribeEach(subscription)), ); }, - getBookDataTracker, + getBookDataTracker(brand) { + return E.when(E(publicFacet).getBookDataUpdates(brand), subscription => + subscriptionTracker(t, subscribeEach(subscription)), + ); + }, getReserveBalance(keyword) { const reserveCF = E.get(reserveKit).creatorFacet; return E.get(E(reserveCF).getAllocations())[keyword]; @@ -919,10 +923,14 @@ test.serial('onDeadline exit, with chainStorage RPC snapshot', async t => { /** @type {BookDataTracker} */ const bookTracker = await driver.getBookDataTracker(collateral.brand); - - await bookTracker.assertChange({ - collateralAvailable: { value: 100n }, - startCollateral: { value: 100n }, + await bookTracker.assertInitial({ + collateralAvailable: collateral.make(100n), + currentPriceLevel: null, + proceedsRaised: undefined, + remainingProceedsGoal: null, + startCollateral: collateral.make(100n), + startPrice: null, + startProceedsGoal: null, }); await driver.updatePriceAuthority( @@ -955,6 +963,22 @@ test.serial('onDeadline exit, with chainStorage RPC snapshot', async t => { t.is(await E(exitingSeat).getOfferResult(), 'Your bid has been accepted'); t.false(await E(exitingSeat).hasExited()); + await driver.bidForCollateralSeat( + bid.make(200n), + collateral.make(250n), + undefined, + ); + driver.bidForCollateralSeat( + bid.make(20n), + collateral.make(200n), + makeRatioFromAmounts(bid.make(50n), bid.make(100n)), + ); + driver.bidForCollateralSeat( + bid.make(40n), + collateral.make(2000n), + makeRatioFromAmounts(bid.make(80n), bid.make(100n)), + ); + await bookTracker.assertChange({ startPrice: makeRatioFromAmounts( bid.make(1_100_000_000n), @@ -964,6 +988,7 @@ test.serial('onDeadline exit, with chainStorage RPC snapshot', async t => { await driver.advanceTo(170n, 'wait'); await bookTracker.assertChange({}); + await bookTracker.assertChange({}); await bookTracker.assertChange({ collateralAvailable: { value: 0n }, @@ -980,6 +1005,8 @@ test.serial('onDeadline exit, with chainStorage RPC snapshot', async t => { await scheduleTracker.assertChange({ nextDescendingStepTime: { absValue: 180n }, }); + await bookTracker.assertChange({}); + await bookTracker.assertChange({}); await bookTracker.assertChange({ currentPriceLevel: { numerator: { value: 9_350_000_000_000n } }, }); @@ -1058,9 +1085,14 @@ test.serial('add assets to open auction', async t => { ); const bookTracker = await driver.getBookDataTracker(collateral.brand); - await bookTracker.assertChange({ - collateralAvailable: { value: 1000n }, - startCollateral: { value: 1000n }, + await bookTracker.assertInitial({ + collateralAvailable: collateral.make(1000n), + currentPriceLevel: null, + proceedsRaised: undefined, + remainingProceedsGoal: null, + startCollateral: collateral.make(1000n), + startPrice: null, + startProceedsGoal: null, }); const scheduleTracker = await driver.getScheduleTracker(); await scheduleTracker.assertInitial({ @@ -1189,34 +1221,39 @@ test.serial('multiple collaterals', async t => { ); // offers 290 for up to 300 at 1.1 * .875, so will trigger at the first discount + const price = makeRatioFromAmounts(bid.make(950n), collateral.make(1000n)); const bidderSeat1C = await driver.bidForCollateralSeat( bid.make(265n), collateral.make(300n), - makeRatioFromAmounts(bid.make(950n), collateral.make(1000n)), + price, ); t.is(await E(bidderSeat1C).getOfferResult(), 'Your bid has been accepted'); + driver.getTimerService().getCurrentTimestamp(); // offers up to 500 for 2000 at 1.1 * 75%, so will trigger at second discount step + const scale2C = makeRatioFromAmounts(bid.make(75n), bid.make(100n)); const bidderSeat2C = await driver.bidForCollateralSeat( bid.make(500n), collateral.make(2000n), - makeRatioFromAmounts(bid.make(75n), bid.make(100n)), + scale2C, ); t.is(await E(bidderSeat2C).getOfferResult(), 'Your bid has been accepted'); // offers 50 for 200 at .25 * 50% discount, so triggered at third step + const scale1A = makeRatioFromAmounts(bid.make(50n), bid.make(100n)); const bidderSeat1A = await driver.bidForCollateralSeat( bid.make(23n), asset.make(200n), - makeRatioFromAmounts(bid.make(50n), bid.make(100n)), + scale1A, ); t.is(await E(bidderSeat1A).getOfferResult(), 'Your bid has been accepted'); // offers 100 for 300 at .25 * 33%, so triggered at fourth step + const price2A = makeRatioFromAmounts(bid.make(100n), asset.make(1000n)); const bidderSeat2A = await driver.bidForCollateralSeat( bid.make(19n), asset.make(300n), - makeRatioFromAmounts(bid.make(100n), asset.make(1000n)), + price2A, ); t.is(await E(bidderSeat2A).getOfferResult(), 'Your bid has been accepted');