diff --git a/package.json b/package.json index fbfa4c8b59b..633debfc2f2 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "packages/bundle-source", "packages/import-bundle", "packages/eventual-send", + "packages/governance", "packages/promise-kit", "packages/tame-metering", "packages/transform-metering", diff --git a/packages/governance/CHANGELOG.md b/packages/governance/CHANGELOG.md new file mode 100644 index 00000000000..767840ada8e --- /dev/null +++ b/packages/governance/CHANGELOG.md @@ -0,0 +1,5 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + diff --git a/packages/governance/NEWS.md b/packages/governance/NEWS.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/governance/jsconfig.json b/packages/governance/jsconfig.json new file mode 100644 index 00000000000..b3b497b681f --- /dev/null +++ b/packages/governance/jsconfig.json @@ -0,0 +1,18 @@ +// This file can contain .js-specific Typescript compiler config. +{ + "compilerOptions": { + "target": "esnext", + + "noEmit": true, +/* + // The following flags are for creating .d.ts files: + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, +*/ + "downlevelIteration": true, + "strictNullChecks": true, + "moduleResolution": "node", + }, + "include": ["src/**/*.js", "test/**/*.js"], +} diff --git a/packages/governance/package.json b/packages/governance/package.json new file mode 100644 index 00000000000..d30f97062fa --- /dev/null +++ b/packages/governance/package.json @@ -0,0 +1,71 @@ +{ + "name": "@agoric/governance", + "version": "0.1.0", + "description": "Core governance support", + "parsers": { + "js": "mjs" + }, + "main": "src/paramManager.js", + "engines": { + "node": ">=14.15.0" + }, + "scripts": { + "build": "exit 0", + "test": "ava", + "test:xs": "exit 0", + "lint-fix": "yarn lint:eslint --fix && yarn lint:types", + "lint-check": "yarn lint", + "lint": "yarn lint:types && yarn lint:eslint", + "lint:eslint": "eslint '**/*.js'", + "lint:types": "tsc -p jsconfig.json" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Agoric/agoric-sdk.git" + }, + "author": "Agoric", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/Agoric/agoric-sdk/issues" + }, + "homepage": "https://github.com/Agoric/agoric-sdk#readme", + "dependencies": { + "@agoric/assert": "^0.3.0", + "@agoric/ertp": "^0.11.2", + "@agoric/marshal": "^0.4.13", + "@agoric/nat": "^4.1.0", + "@agoric/notifier": "^0.3.14", + "@agoric/store": "^0.4.15", + "@agoric/zoe": "^0.16.0" + }, + "devDependencies": { + "@agoric/install-ses": "^0.5.13", + "ava": "^3.12.1", + "esm": "agoric-labs/esm#Agoric-built" + }, + "files": [ + "src/", + "NEWS.md" + ], + "ava": { + "files": [ + "test/**/test-*.js" + ], + "require": [ + "esm" + ], + "timeout": "10m" + }, + "eslintConfig": { + "extends": [ + "@agoric" + ] + }, + "prettier": { + "trailingComma": "all", + "singleQuote": true + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/governance/src/paramManager.js b/packages/governance/src/paramManager.js new file mode 100644 index 00000000000..c1a9a1a0d1e --- /dev/null +++ b/packages/governance/src/paramManager.js @@ -0,0 +1,127 @@ +// @ts-check + +import { assert, details as X } from '@agoric/assert'; +import { assertIsRatio } from '@agoric/zoe/src/contractSupport'; +import { AmountMath, looksLikeBrand } from '@agoric/ertp'; +import { Far } from '@agoric/marshal'; +import { assertKeywordName } from '@agoric/zoe/src/cleanProposal'; + +/** + * @type {{ + * AMOUNT: 'amount', + * BRAND: 'brand', + * INSTANCE: 'instance', + * INSTALLATION: 'installation', + * NAT: 'nat', + * RATIO: 'ratio', + * STRING: 'string', + * UNKNOWN: 'unknown', + * }} + */ +const ParamType = { + AMOUNT: 'amount', + BRAND: 'brand', + INSTANCE: 'instance', + INSTALLATION: 'installation', + NAT: 'nat', + RATIO: 'ratio', + STRING: 'string', + UNKNOWN: 'unknown', +}; +harden(ParamType); + +const assertType = (type, value, name) => { + switch (type) { + case ParamType.AMOUNT: + // It would be nice to have a clean way to assert something is an amount. + AmountMath.coerce(value.brand, value); + break; + case ParamType.BRAND: + assert( + looksLikeBrand(value), + X`value for ${name} must be a brand, was ${value}`, + ); + break; + case ParamType.INSTALLATION: + // TODO(3344): add a better assertion once Zoe validates installations + assert( + typeof value === 'object' && !Object.getOwnPropertyNames(value).length, + X`value for ${name} must be an Installation, was ${value}`, + ); + break; + case ParamType.INSTANCE: + // TODO(3344): add a better assertion once Zoe validates instances + assert( + typeof value === 'object' && !Object.getOwnPropertyNames(value).length, + X`value for ${name} must be an Instance, was ${value}`, + ); + break; + case ParamType.NAT: + assert.typeof(value, 'bigint'); + break; + case ParamType.RATIO: + assertIsRatio(value); + break; + case ParamType.STRING: + assert.typeof(value, 'string'); + break; + // This is an escape hatch for types we haven't added yet. If you need to + // use it, please file an issue and ask us to support the new type. + case ParamType.UNKNOWN: + break; + default: + assert.fail(X`unrecognized type ${type}`); + } +}; + +const parse = paramDesc => { + const typesAndValues = {}; + // manager has an updateFoo() for each Foo param. It will be returned. + const manager = {}; + + paramDesc.forEach(({ name, value, type }) => { + // we want to create function names like updateFeeRatio(), so we insist that + // the name has Keyword-nature. + assertKeywordName(name); + + assert( + !typesAndValues[name], + X`each parameter name must be unique: ${name} duplicated`, + ); + assertType(type, value, name); + + typesAndValues[name] = { type, value }; + manager[`update${name}`] = newValue => { + assertType(type, newValue, name); + typesAndValues[name].value = newValue; + }; + }); + + const getParams = () => { + /** @type {Record} */ + const descriptions = {}; + Object.getOwnPropertyNames(typesAndValues).forEach(name => { + descriptions[name] = { + name, + type: typesAndValues[name].type, + value: typesAndValues[name].value, + }; + }); + return harden(descriptions); + }; + + return { getParams, manager }; +}; + +/** @type {BuildParamManager} */ +const buildParamManager = paramDesc => { + const { getParams, manager } = parse(paramDesc); + + return Far('param manager', { + getParams, + ...manager, + }); +}; +harden(buildParamManager); + +export { ParamType, buildParamManager }; diff --git a/packages/governance/src/types.js b/packages/governance/src/types.js new file mode 100644 index 00000000000..6cbf3324fd4 --- /dev/null +++ b/packages/governance/src/types.js @@ -0,0 +1,34 @@ +// @ts-check + +/** + * @typedef { 'amount' | 'brand' | 'installation' | 'instance' | 'nat' | 'ratio' | 'string' | 'unknown' } ParamType + */ + +/** + * @typedef { Amount | Brand | Installation | Instance | bigint | Ratio | string | unknown } ParamValue + */ + +/** + * @typedef {Object} ParamDescription + * @property {string} name + * @property {ParamValue} value + * @property {ParamType} type + */ + +/** + * @typedef {Object} ParamManagerBase + * @property {() => Record} getParams + * + * @typedef {{ [updater: string]: (arg: ParamValue) => void }} ParamManagerUpdaters + * @typedef {ParamManagerBase & ParamManagerUpdaters} ParamManagerFull + */ + +/** + * @typedef {Array} ParamDescriptions + */ + +/** + * @callback BuildParamManager + * @param {ParamDescriptions} paramDesc + * @returns {ParamManagerFull} + */ diff --git a/packages/governance/test/test-param-manager.js b/packages/governance/test/test-param-manager.js new file mode 100644 index 00000000000..cbdb95f9fe9 --- /dev/null +++ b/packages/governance/test/test-param-manager.js @@ -0,0 +1,285 @@ +// @ts-check + +import { test } from '@agoric/zoe/tools/prepare-test-env-ava'; +import '@agoric/zoe/exported'; +import { AmountMath, AssetKind, makeIssuerKit } from '@agoric/ertp'; +import { makeRatio } from '@agoric/zoe/src/contractSupport'; + +import { makeHandle } from '@agoric/zoe/src/makeHandle'; +import { buildParamManager, ParamType } from '../src/paramManager'; + +const BASIS_POINTS = 10_000; + +test('params one Nat', async t => { + const numberKey = 'Number'; + const numberDescription = { + name: numberKey, + value: 13n, + type: ParamType.NAT, + }; + const { getParams, updateNumber } = buildParamManager([numberDescription]); + t.deepEqual(getParams()[numberKey], numberDescription); + updateNumber(42n); + t.deepEqual(getParams()[numberKey].value, 42n); + + t.throws( + () => updateNumber(18.1), + { + message: '18.1 must be a bigint', + }, + 'value should be a nat', + ); + t.throws( + () => updateNumber(13), + { + message: '13 must be a bigint', + }, + 'must be bigint', + ); +}); + +test('params one String', async t => { + const stringKey = 'String'; + const stringDescription = { + name: stringKey, + value: 'foo', + type: ParamType.STRING, + }; + const { getParams, updateString } = buildParamManager([stringDescription]); + t.deepEqual(getParams()[stringKey], stringDescription); + updateString('bar'); + t.deepEqual(getParams()[stringKey].value, 'bar'); + + t.throws( + () => updateString(18.1), + { + message: '18.1 must be a string', + }, + 'value should be a string', + ); +}); + +test('params one Amount', async t => { + const amountKey = 'Amount'; + const { brand } = makeIssuerKit('roses', AssetKind.SET); + const amountDescription = { + name: amountKey, + value: AmountMath.makeEmpty(brand), + type: ParamType.AMOUNT, + }; + const { getParams, updateAmount } = buildParamManager([amountDescription]); + t.deepEqual(getParams()[amountKey], amountDescription); + updateAmount(AmountMath.make(brand, [13])); + t.deepEqual(getParams()[amountKey].value, AmountMath.make(brand, [13])); + + t.throws( + () => updateAmount(18.1), + { + message: 'The brand "[undefined]" doesn\'t look like a brand.', + }, + 'value should be a amount', + ); +}); + +test('params one BigInt', async t => { + const bigintKey = 'Bigint'; + const bigIntDescription = { + name: bigintKey, + value: 314159n, + type: ParamType.NAT, + }; + const { getParams, updateBigint } = buildParamManager([bigIntDescription]); + t.deepEqual(getParams()[bigintKey], bigIntDescription); + updateBigint(271828182845904523536n); + t.deepEqual(getParams()[bigintKey].value, 271828182845904523536n); + + t.throws( + () => updateBigint(18.1), + { + message: '18.1 must be a bigint', + }, + 'value should be a bigint', + ); +}); + +test('params one ratio', async t => { + const ratioKey = 'Ratio'; + const { brand } = makeIssuerKit('roses', AssetKind.SET); + const ratioDescription = { + name: ratioKey, + value: makeRatio(7, brand), + type: ParamType.RATIO, + }; + const { getParams, updateRatio } = buildParamManager([ratioDescription]); + t.deepEqual(getParams()[ratioKey], ratioDescription); + updateRatio(makeRatio(701, brand, BASIS_POINTS)); + t.deepEqual(getParams()[ratioKey].value, makeRatio(701, brand, BASIS_POINTS)); + + t.throws( + () => updateRatio(18.1), + { + message: 'Ratio 18.1 must be a record with 2 fields.', + }, + 'value should be a ratio', + ); +}); + +test('params one brand', async t => { + const brandKey = 'Brand'; + const { brand: roseBrand } = makeIssuerKit('roses', AssetKind.SET); + const { brand: thornBrand } = makeIssuerKit('thorns'); + const brandDescription = { + name: brandKey, + value: roseBrand, + type: ParamType.BRAND, + }; + const { getParams, updateBrand } = buildParamManager([brandDescription]); + t.deepEqual(getParams()[brandKey], brandDescription); + updateBrand(thornBrand); + t.deepEqual(getParams()[brandKey].value, thornBrand); + + t.throws( + () => updateBrand(18.1), + { + message: 'value for "Brand" must be a brand, was 18.1', + }, + 'value should be a brand', + ); +}); + +test('params one unknown', async t => { + const stuffKey = 'Stuff'; + const { brand: stiltonBrand } = makeIssuerKit('stilton', AssetKind.SET); + const stuffDescription = { + name: stuffKey, + value: stiltonBrand, + type: ParamType.UNKNOWN, + }; + const { getParams, updateStuff } = buildParamManager([stuffDescription]); + t.deepEqual(getParams()[stuffKey], stuffDescription); + updateStuff(18.1); + t.deepEqual(getParams()[stuffKey].value, 18.1); +}); + +test('params one instance', async t => { + const instanceKey = 'Instance'; + // this is sufficient for the current type check. When we add + // isInstance() (#3344), we'll need to make a mockZoe. + const instanceHandle = makeHandle('Instance'); + const instanceDescription = { + name: instanceKey, + value: instanceHandle, + type: ParamType.INSTANCE, + }; + const { getParams, updateInstance } = buildParamManager([ + instanceDescription, + ]); + t.deepEqual(getParams()[instanceKey], instanceDescription); + t.throws( + () => updateInstance(18.1), + { + message: 'value for "Instance" must be an Instance, was 18.1', + }, + 'value should be an Instance', + ); + const handle2 = makeHandle('another Instance'); + updateInstance(handle2); + t.deepEqual(getParams()[instanceKey].value, handle2); +}); + +test('params one installation', async t => { + const installationKey = 'Installation'; + // this is sufficient for the current type check. When we add + // isInstallation() (#3344), we'll need to make a mockZoe. + const installationHandle = makeHandle('installation'); + const installationDescription = { + name: installationKey, + value: installationHandle, + type: ParamType.INSTALLATION, + }; + const { getParams, updateInstallation } = buildParamManager([ + installationDescription, + ]); + t.deepEqual(getParams()[installationKey], installationDescription); + t.throws( + () => updateInstallation(18.1), + { + message: 'value for "Installation" must be an Installation, was 18.1', + }, + 'value should be an installation', + ); + const handle2 = makeHandle('another installation'); + updateInstallation(handle2); + t.deepEqual(getParams()[installationKey].value, handle2); +}); + +test('params duplicate entry', async t => { + const stuffKey = 'Stuff'; + const { brand: stiltonBrand } = makeIssuerKit('stilton', AssetKind.SET); + t.throws( + () => + buildParamManager([ + { + name: stuffKey, + value: 37n, + type: ParamType.NAT, + }, + { + name: stuffKey, + value: stiltonBrand, + type: ParamType.UNKNOWN, + }, + ]), + { + message: `each parameter name must be unique: "Stuff" duplicated`, + }, + ); +}); + +test('params unknown type', async t => { + const stuffKey = 'Stuff'; + const stuffDescription = { + name: stuffKey, + value: 'It was the best of times, it was the worst of times', + type: 'quote', + }; + // @ts-ignore illegal value for testing + t.throws(() => buildParamManager([stuffDescription]), { + message: 'unrecognized type "quote"', + }); +}); + +test('params multiple values', t => { + const stuffKey = 'Stuff'; + const natKey = 'Nat'; + const { brand: parmesanBrand } = makeIssuerKit('parmesan', AssetKind.SET); + const cheeseDescription = { + name: stuffKey, + value: parmesanBrand, + type: ParamType.UNKNOWN, + }; + const constantDescription = { + name: natKey, + value: 602214076000000000000000n, + type: ParamType.NAT, + }; + const { getParams, updateNat, updateStuff } = buildParamManager([ + cheeseDescription, + constantDescription, + ]); + t.deepEqual(getParams()[stuffKey], cheeseDescription); + updateStuff(18.1); + const floatDescription = { + name: stuffKey, + value: 18.1, + type: ParamType.UNKNOWN, + }; + t.deepEqual(getParams()[stuffKey], floatDescription); + t.deepEqual(getParams()[natKey], constantDescription); + t.deepEqual(getParams(), { + Nat: constantDescription, + Stuff: floatDescription, + }); + updateNat(299792458n); + t.deepEqual(getParams()[natKey].value, 299792458n); +});