diff --git a/packages/psm-tool/.gitignore b/packages/psm-tool/.gitignore new file mode 100644 index 00000000000..a0e3405863a --- /dev/null +++ b/packages/psm-tool/.gitignore @@ -0,0 +1 @@ +app/.clasp.json diff --git a/packages/psm-tool/LICENSE b/packages/psm-tool/LICENSE new file mode 100644 index 00000000000..261eeb9e9f8 --- /dev/null +++ b/packages/psm-tool/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/psm-tool/NEWS.md b/packages/psm-tool/NEWS.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/psm-tool/README.md b/packages/psm-tool/README.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/psm-tool/SECURITY.md b/packages/psm-tool/SECURITY.md new file mode 100644 index 00000000000..9365d51e125 --- /dev/null +++ b/packages/psm-tool/SECURITY.md @@ -0,0 +1,38 @@ +# Security Policy + +## Supported Versions + +The SES package and associated Endo packages are still undergoing development and security review, and all +users are encouraged to use the latest version available. Security fixes will +be made for the most recent branch only. + +## Coordinated Vulnerability Disclosure of Security Bugs + +SES stands for fearless cooperation, and strong security requires strong collaboration with security researchers. If you believe that you have found a security sensitive bug that should not be disclosed until a fix has been made available, we encourage you to report it. To report a bug in HardenedJS, you have several options that include: + +* Reporting the issue to the [Agoric HackerOne vulnerability rewards program](hackerone.com/agoric). + +* Sending an email to security at (@) agoric.com., encrypted or unencrypted. To encrypt, please use @Warner’s personal GPG key [A476E2E6 11880C98 5B3C3A39 0386E81B 11CAA07A](http://www.lothar.com/warner-gpg.html) . + +* Sending a message on Keybase to `@agoric_security`, or sharing code and other log files via Keybase’s encrypted file system. ((_keybase_private/agoric_security,$YOURNAME). + +* It is important to be able to provide steps that reproduce the issue and demonstrate its impact with a Proof of Concept example in an initial bug report. Before reporting a bug, a reporter may want to have another trusted individual reproduce the issue. + +* A bug reporter can expect acknowledgment of a potential vulnerability reported through [security@agoric.com](mailto:security@agoric.com) within one business day of submitting a report. If an acknowledgement of an issue is not received within this time frame, especially during a weekend or holiday period, please reach out again. Any issues reported to the HackerOne program will be acknowledged within the time frames posted on the program page. + * The bug triage team and Agoric code maintainers are primarily located in the San Francisco Bay Area with business hours in [Pacific Time](https://www.timeanddate.com/worldclock/usa/san-francisco) . + +* For the safety and security of those who depend on the code, bug reporters should avoid publicly sharing the details of a security bug on Twitter, Discord, Telegram, or in public Github issues during the coordination process. + +* Once a vulnerability report has been received and triaged: + * Agoric code maintainers will confirm whether it is valid, and will provide updates to the reporter on validity of the report. + * It may take up to 72 hours for an issue to be validated, especially if reported during holidays or on weekends. + +* When the Agoric team has verified an issue, remediation steps and patch release timeline information will be shared with the reporter. + * Complexity, severity, impact, and likelihood of exploitation are all vital factors that determine the amount of time required to remediate an issue and distribute a software patch. + * If an issue is Critical or High Severity, Agoric code maintainers will release a security advisory to notify impacted parties to prepare for an emergency patch. + * While the current industry standard for vulnerability coordination resolution is 90 days, Agoric code maintainers will strive to release a patch as quickly as possible. + +When a bug patch is included in a software release, the Agoric code maintainers will: + * Confirm the version and date of the software release with the reporter. + * Provide information about the security issue that the software release resolves. + * Credit the bug reporter for discovery by adding thanks in release notes, securing a CVE designation, or adding the researcher’s name to a Hall of Fame. diff --git a/packages/psm-tool/app/.npmignore b/packages/psm-tool/app/.npmignore new file mode 100644 index 00000000000..48f089089fe --- /dev/null +++ b/packages/psm-tool/app/.npmignore @@ -0,0 +1 @@ +.clasp.json diff --git a/packages/psm-tool/app/appsscript.json b/packages/psm-tool/app/appsscript.json new file mode 100644 index 00000000000..50abad2edb8 --- /dev/null +++ b/packages/psm-tool/app/appsscript.json @@ -0,0 +1,7 @@ +{ + "timeZone": "America/Chicago", + "dependencies": { + }, + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8" +} \ No newline at end of file diff --git a/packages/psm-tool/app/psm-sheets-lib.js b/packages/psm-tool/app/psm-sheets-lib.js new file mode 100644 index 00000000000..504cba04adb --- /dev/null +++ b/packages/psm-tool/app/psm-sheets-lib.js @@ -0,0 +1,108 @@ +/* eslint-disable no-unused-vars */ +/* global Utilities, UrlFetchApp */ +// https://developers.google.com/apps-script/reference/utilities/utilities?hl=en#base64Decode(String) + +// decode base64 to string +const atob = encoded => { + const decoded = Utilities.base64Decode(encoded); + return Utilities.newBlob(decoded).getDataAsString(); +}; + +/** + * WARNING: ambient + * + * @param {string} origin + */ +const theWeb = origin => { + const getJSON = url => { + const text = UrlFetchApp.fetch(origin + url); + return JSON.parse(text); + }; + return { getJSON }; +}; + +const fromBoard = makeFromBoard(); // WARNING: global mutable state + +const getContractEntries = async origin => { + const { getJSON } = theWeb(origin); + const agoricNames = await makeAgoricNames(fromBoard, getJSON); + const { instance, governance: gov } = await getContractState( + fromBoard, + agoricNames, + { getJSON }, + ); + return Object.entries({ + boardId: instance.boardId, + WantStableFee: asPercent(gov.WantStableFee.value) / 100, + GiveStableFee: asPercent(gov.GiveStableFee.value) / 100, + }); +}; + +const testContract = async () => { + const origin = 'https://ollinet.rpc.agoric.net:443'; + const c = await getContractEntries(origin); + console.log(c); +}; + +const simpleWalletState = async (addr, origin) => { + const { getJSON } = theWeb(origin); + const state = await getWalletState(addr, fromBoard, { getJSON }); + const agoricNames = await makeAgoricNames(fromBoard, getJSON); + return [ + ...simplePurseBalances(state.purses).map(([val, brand]) => [ + 'purse', + val, + brand, + ]), + ...simpleOffers(state, agoricNames), + ]; +}; + +const testSimpleWalletState = async () => { + const simple = await simpleWalletState( + 'agoric1ggkanx6sv492tr3j2j4lf8kvlw9vtutwgr2fhn', + ); + console.log(simple); +}; + +const offerAction = (direction, qty, fee, boardId, indent = 0) => { + const id = Date.now(); + const offer = makePSMSpendAction( + { boardId, feePct: fee * 100, [direction]: qty }, + id, + ); + return JSON.stringify(offer, null, indent); +}; + +const testFormatOffer = () => { + const offer = offerAction('wantStable', 10, 0.0001, 'board123', 2); + console.log(offer); +}; + +const formatCommands = (action, fromAddr, node, chainId) => { + const script = `read -r -d '' psmTrade <<'EOF' +${action} +EOF +agd tx swingset wallet-action --allow-spend "$psmTrade" --from ${fromAddr} --node=${node} --chain-id=${chainId} +`; + return script; +}; + +function testFormatCommands() { + const actual = formatCommands('{give}', 'agoric123', 'https://rpc'); + console.log(actual); +} + +function votingPower() { + const stuff = UrlFetchApp.fetch('https://ollinet.rpc.agoric.net/status?'); + const data = JSON.parse(stuff); + + console.log('is this thing on?', data); + return data.result.validator_info.voting_power; +} + +function updateClock(ref = 'A13') { + const sheet = SpreadsheetApp.getActiveSheet(); + const t = new Date(); + sheet.getRange(ref).setValue(t); +} diff --git a/packages/psm-tool/jsconfig.json b/packages/psm-tool/jsconfig.json new file mode 100644 index 00000000000..5d2fe839773 --- /dev/null +++ b/packages/psm-tool/jsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "noEmit": true, + "downlevelIteration": true, + "strictNullChecks": true, + "moduleResolution": "node" + }, + "include": ["**/*.js", "**/*.ts"] +} diff --git a/packages/psm-tool/package.json b/packages/psm-tool/package.json new file mode 100644 index 00000000000..e3684e75b48 --- /dev/null +++ b/packages/psm-tool/package.json @@ -0,0 +1,68 @@ +{ + "name": "@agoric/psm-tool", + "version": "0.1.0", + "private": false, + "description": "Paritity Stability Module Tool.", + "keywords": [], + "author": "Agoric", + "license": "Apache-2.0", + "homepage": "https://github.com/Agoric/agoric-sdk/tree/master/packages/psm-tool#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/Agoric/agoric-sdk.git" + }, + "bugs": { + "url": "https://github.com/Agoric/agoric-sdk/issues" + }, + "type": "module", + "bin": { + "ag-psm-tool": "./scripts/psm-tool.js" + }, + "main": "./src/psm-lib.js", + "module": "./src/psm-lib.js", + "browser": null, + "unpkg": null, + "exports": { + ".": "./src/psm-lib.js", + "./package.json": "./package.json" + }, + "scripts": { + "build": "yarn build:script && yarn build:app", + "build:script": "mkdir -p dist; node scripts/pasteImports.js dist/psm-tool src/psm-lib.js scripts/psm-tool.js", + "build:app": "mkdir -p app/build; sed 's/^export .*//' src/psm-lib.js >app/build/psm-lib.js", + "clasp:push": "yarn build && cd app; clasp push", + "test": "ava", + "lint-fix": "yarn lint:eslint --fix", + "lint": "run-s --continue-on-error lint:*", + "lint:types": "tsc --maxNodeModuleJsDepth 4 -p jsconfig.json", + "lint:eslint": "eslint ." + }, + "dependencies": {}, + "devDependencies": { + "@google/clasp": "^2.4.1", + "ava": "^4.3.1" + }, + "files": [ + "*.js", + "*.ts", + "LICENSE*", + "app", + "dist", + "src" + ], + "publishConfig": { + "access": "public" + }, + "eslintConfig": { + "ignorePatterns": [ + "dist/*", + "build/*" + ] + }, + "ava": { + "files": [ + "test/**/test-*.js" + ], + "timeout": "2m" + } +} diff --git a/packages/psm-tool/scripts/pasteImports.js b/packages/psm-tool/scripts/pasteImports.js new file mode 100644 index 00000000000..e54f1c9d07e --- /dev/null +++ b/packages/psm-tool/scripts/pasteImports.js @@ -0,0 +1,33 @@ +// @ts-check + +const dieTrying = () => { + throw Error(); +}; + +/** + * + * @param {string[]} argv + * @param {object} io + * @param {typeof import('fs').promises.readFile} io.readFile + * @param {typeof import('fs').promises.writeFile} io.writeFile + */ +const main = async (argv, { readFile, writeFile }) => { + const dest = argv.shift() || dieTrying(); + const mainFn = argv.pop() || dieTrying(); + const parts = []; + for await (const fn of argv) { + const txt = await readFile(fn, 'utf-8'); + parts.push(txt.replace(/^export /gm, '')); + } + const txt = await readFile(mainFn, 'utf-8'); + const script = txt.replace(/import {[^}]*} from [^;]+;/m, parts.join('\n')); + + await writeFile(dest, script); + console.info('built', dest, 'from', mainFn, 'with', argv); +}; + +(async () => { + // eslint-disable-next-line no-undef + const { argv } = process; + import('fs').then(fs => main(argv.slice(2), fs.promises)); +})().catch(err => console.error(err)); diff --git a/packages/psm-tool/scripts/psm-tool.js b/packages/psm-tool/scripts/psm-tool.js new file mode 100755 index 00000000000..95d181b8658 --- /dev/null +++ b/packages/psm-tool/scripts/psm-tool.js @@ -0,0 +1,202 @@ +#!/usr/bin/env node +/* global process, fetch */ +// @ts-check +import { + assert, + asPercent, + getContractState, + getWalletState, + makeAgoricNames, + makeFromBoard, + makePSMSpendAction, + miniMarshal, + networks, + simpleOffers, + simplePurseBalances, + vstorage, +} from '../src/psm-lib.js'; + +const USAGE = ` +Usage: +to get contract instance boardId and, optionally, fees + psm-tool --contract [--verbose] +to write an offer to stdout + psm-tool --wantStable ANCHOR_TOKENS --boardId BOARD_ID [--feePct PCT] + psm-tool --giveStable IST_TOKENS --boardId BOARD_ID [--feePct PCT] +to get succinct offer status and purse balances + psm-tool --wallet AGORIC_ADDRESS + +NOTE: --contract and --wallet may need --experimental-fetch node option. + +For example: + +psmInstance=$(psm-tool --contract) +psm-tool --contract --verbose # to get fees +psm-tool --wantStable 100 --boardId $psmInstance --feePct 0.01 >,psm-offer-action.json + +# sign and send +agd --node=${networks.xnet.rpc} --chain-id=agoricxnet-13 \ + --from=LEDGER_KEY_NAME --sign-mode=amino-json \ + tx swingset wallet-action --allow-spend "$(cat ,psm-offer-action.json)" + +# check results +psm-tool --wallet agoric1.... +`; + +/** + * @param {string[]} argv + * @param {string[]} [flagNames] options that don't take values + */ +const parseArgs = (argv, flagNames = []) => { + /** @type {string[]} */ + const args = []; + /** @type {Record} */ + const opts = {}; + /** @type {Record} */ + const flags = {}; + + let ix = 0; + while (ix < argv.length) { + const arg = argv[ix]; + if (arg.startsWith('--')) { + const opt = arg.slice('--'.length); + if (flagNames.includes(arg)) { + flags[opt] = true; + } else { + ix += 1; + const val = argv[ix]; + opts[opt] = val; + } + } else { + args.push(arg); + } + ix += 1; + } + return { args, opts, flags }; +}; + +// const log = label => x => { +// console.error(label, x); +// return x; +// }; +const log = _label => x => x; + +const fmtRecordOfLines = record => { + const { stringify } = JSON; + const groups = Object.entries(record).map(([key, items]) => [ + key, + items.map(item => ` ${stringify(item)}`), + ]); + const lineEntries = groups.map( + ([key, lines]) => ` ${stringify(key)}: [\n${lines.join(',\n')}\n ]`, + ); + return `{\n${lineEntries.join(',\n')}\n}`; +}; + +/** + * @param {{wallet?: string, net?: string}} opts + * @param {{contract?: boolean, verbose?: boolean}} flags + * @param {object} io + * @param {typeof fetch} io.fetch + */ +const online = async (opts, flags, { fetch }) => { + const net = networks[opts.net || 'local']; + assert(net, opts.net); + const getJSON = async url => (await fetch(log('url')(net.rpc + url))).json(); + + if (flags.verbose) { + // const status = await getJSON(`${RPC_BASE}/status?`); + // console.log({ status }); + const raw = await getJSON(vstorage.url()); + const top = vstorage.decode(raw); + console.error( + JSON.stringify(['vstorage published.*', JSON.parse(top).children]), + ); + } + + const fromBoard = makeFromBoard(); + const agoricNames = await makeAgoricNames(fromBoard, getJSON); + + if (flags.contract) { + const { instance, governance } = await getContractState( + fromBoard, + agoricNames, + { + getJSON, + }, + ); + flags.verbose && console.error('psm', instance, Object.keys(governance)); + flags.verbose && + console.error( + 'WantStableFee', + asPercent(governance.WantStableFee.value), + '%', + 'GiveStableFee', + asPercent(governance.GiveStableFee.value), + '%', + ); + console.info(instance.boardId); + return 0; + } else if (opts.wallet) { + const state = await getWalletState(opts.wallet, fromBoard, { + getJSON, + }); + const { purses } = state; + // console.log(JSON.stringify(offers, null, 2)); + // console.log(JSON.stringify({ offers, purses }, bigIntReplacer, 2)); + const summary = { + balances: simplePurseBalances(purses), + offers: simpleOffers(state, agoricNames), + }; + console.log(fmtRecordOfLines(summary)); + return 0; + } + + return 1; +}; + +/** + * @param {string[]} argv + * @param {object} io + * @param {typeof fetch} [io.fetch] + * @param {() => Date} io.clock + */ +const main = async (argv, { fetch, clock }) => { + const { opts, flags } = parseArgs(argv, ['--contract', '--verbose']); + + if (flags.contract || opts.wallet) { + assert(fetch, 'missing fetch API; try --experimental-fetch?'); + // @ts-expect-error what's up with typeof fetch??? + return online(opts, flags, { fetch }); + } + + if (!(opts.wantStable || opts.giveStable)) { + console.error(USAGE); + return 1; + } + + const fromBoard = makeFromBoard(); + const net = networks[opts.net || 'local']; + assert(net, opts.net); + const getJSON = async url => (await fetch(log('url')(net.rpc + url))).json(); + const agoricNames = await makeAgoricNames(fromBoard, getJSON); + const instance = agoricNames.instance.psm; + const spendAction = makePSMSpendAction( + instance, + agoricNames.brand, + // @ts-expect-error + opts, + clock().valueOf(), + ); + console.log(miniMarshal().serialize(spendAction)); + return 0; +}; + +main([...process.argv], { + // support pre-fetch node for some modes + fetch: typeof fetch === 'function' ? fetch : undefined, + clock: () => new Date(), +}).then( + code => process.exit(code), + err => console.error(err), +); diff --git a/packages/psm-tool/src/psm-lib.js b/packages/psm-tool/src/psm-lib.js new file mode 100755 index 00000000000..558d063a4c5 --- /dev/null +++ b/packages/psm-tool/src/psm-lib.js @@ -0,0 +1,400 @@ +// @ts-check +/* global atob */ + +const networks = { + local: { rpc: 'http://0.0.0.0:26657', chainId: 'agoric' }, + xnet: { rpc: 'https://xnet.rpc.agoric.net:443', chainId: 'agoricxnet-13' }, + ollinet: { + rpc: 'https://ollinet.rpc.agoric.net:443', + chainId: 'agoricollinet-21', + }, +}; + +/** + * @param {unknown} cond + * @param {unknown} [msg] + */ +const assert = (cond, msg = undefined) => { + if (!cond) { + throw typeof msg === 'string' ? Error(msg || 'check failed') : msg; + } +}; + +const { freeze } = Object; // IOU harden + +const COSMOS_UNIT = BigInt(1000000); + +// eslint-disable-next-line no-unused-vars +const bigIntReplacer = (_key, val) => + typeof val === 'bigint' ? Number(val) : val; + +/** + * zoe/ERTP types + * + * @typedef {Record} AmountKeywordRecord + * @typedef {string} Keyword + * @typedef {Partial} Proposal + * + * @typedef {{give: AmountKeywordRecord, + * want: AmountKeywordRecord, + * exit: ExitRule + * }} ProposalRecord + * + * @typedef {unknown} ExitRule + */ + +/** @typedef {import('@agoric/smart-wallet/src/offers.js').OfferSpec} OfferSpec */ +/** @typedef {import('@agoric/smart-wallet/src/smartWallet.js').BridgeAction} BridgeAction */ +/** @template T @typedef {import('@agoric/smart-wallet/src/types.js').WalletCapData} WalletCapData */ + +/** + * @param {Record} brands + * @param {({ wantStable: string } | { giveStable: string })} opts + * @param {number} [fee=0] + * @param {string} [anchor] + * @returns {ProposalRecord} + */ +const makePSMProposal = (brands, opts, fee = 0, anchor = 'AUSD') => { + const brand = + 'wantStable' in opts + ? { in: brands[anchor], out: brands.IST } + : { in: brands.IST, out: brands[anchor] }; + const value = + Number('wantStable' in opts ? opts.wantStable : opts.giveStable) * + Number(COSMOS_UNIT); + const adjusted = { + in: BigInt(Math.ceil('wantStable' in opts ? value / (1 - fee) : value)), + out: BigInt(Math.ceil('giveStable' in opts ? value * (1 - fee) : value)), + }; + return { + give: { + In: { brand: brand.in, value: adjusted.in }, + }, + want: { + Out: { brand: brand.out, value: adjusted.out }, + }, + exit: {}, + }; +}; + +/** + * @param {Record} brands + * @param {unknown} instance + * @param {{ feePct?: string } & + * ({ wantStable: string } | { giveStable: string })} opts + * @param {number} timeStamp + * @returns {BridgeAction} + */ +const makePSMSpendAction = (instance, brands, opts, timeStamp) => { + const method = + 'wantStable' in opts + ? 'makeWantStableInvitation' + : 'makeGiveStableInvitation'; // ref psm.js + const proposal = makePSMProposal( + brands, + opts, + opts.feePct ? Number(opts.feePct) / 100 : undefined, + ); + + /** @type {OfferSpec} */ + const offer = { + id: timeStamp, + invitationSpec: { + source: 'contract', + instance, + publicInvitationMaker: method, + }, + proposal, + }; + + /** @type {BridgeAction} */ + const spendAction = { + method: 'executeOffer', + offer, + }; + return spendAction; +}; + +const vstorage = { + url: (path = 'published', { kind = 'children' } = {}) => + `/abci_query?path=%22/custom/vstorage/${kind}/${path}%22&height=0`, + decode: ({ result: { response } }) => { + const { code } = response; + if (code !== 0) { + throw response; + } + const { value } = response; + return atob(value); + }, + /** + * @param {string} path + * @param {(url: string) => Promise} getJSON + */ + read: async (path = 'published', getJSON) => { + const raw = await getJSON(vstorage.url(path, { kind: 'data' })); + return vstorage.decode(raw); + }, +}; + +export const miniMarshal = (slotToVal = (s, _i) => s) => ({ + unserialze: ({ body, slots }) => { + const reviver = (_key, obj) => { + const qclass = obj !== null && typeof obj === 'object' && obj['@qclass']; + // NOTE: hilbert hotel not impl + switch (qclass) { + case 'slot': { + const { index, iface } = obj; + return slotToVal(slots[index], iface); + } + case 'bigint': + return BigInt(obj.digits); + case 'undefined': + return undefined; + default: + return obj; + } + }; + return JSON.parse(body, reviver); + }, + serialize: whole => { + const seen = new Map(); + const slotIndex = v => { + if (seen.has(v)) { + return seen.get(v); + } + const index = seen.size; + seen.set(v, index); + return { index, iface: v.iface }; + }; + const recur = part => { + if (part === null) return null; + if (typeof part === 'bigint') { + return { '@qclass': 'bigint', digits: `${part}` }; + } + if (Array.isArray(part)) { + return part.map(recur); + } + if (typeof part === 'object') { + if ('boardId' in part) { + return { '@qclass': 'slot', ...slotIndex(part.boardId) }; + } + return Object.fromEntries( + Object.entries(part).map(([k, v]) => [k, recur(v)]), + ); + } + return part; + }; + const after = recur(whole); + return { body: JSON.stringify(after), slots: [...seen.keys()] }; + }, +}); + +const makeFromBoard = (slotKey = 'boardId') => { + const cache = new Map(); + const convertSlotToVal = (slot, iface) => { + if (cache.has(slot)) { + return cache.get(slot); + } + const val = freeze({ [slotKey]: slot, iface }); + cache.set(slot, val); + return val; + }; + return freeze({ convertSlotToVal }); +}; +/** @typedef {ReturnType} IdMap */ + +const storageNode = { + /** @param { string } txt */ + parseCapData: txt => { + assert(typeof txt === 'string', typeof txt); + /** @type {{ value: string }} */ + const { value } = JSON.parse(txt); + const specimen = JSON.parse(value); + // without blockHeight, it's the pre-vstreams style + /** @type {{ body: string, slots: string[] }[]} */ + const capDatas = + 'blockHeight' in specimen + ? specimen.values.map(s => JSON.parse(s)) + : [JSON.parse(specimen.value)]; + for (const capData of capDatas) { + assert(typeof capData === 'object' && capData !== null, capData); + assert('body' in capData && 'slots' in capData, capData); + assert(typeof capData.body === 'string', capData); + assert(Array.isArray(capData.slots), capData); + } + return capDatas; + }, + unserialize: (txt, ctx) => { + const capDatas = storageNode.parseCapData(txt); + return capDatas.map(capData => + miniMarshal(ctx.convertSlotToVal).unserialze(capData), + ); + }, +}; + +/** + * @template K, V + * @typedef {[key: K, val: V]} Entry + */ + +const last = xs => xs[xs.length - 1]; + +/** + * @param {IdMap} ctx + * @param {(url: string) => Promise} getJSON + * @param {string[]} [kinds] + */ +const makeAgoricNames = async (ctx, getJSON, kinds = ['brand', 'instance']) => { + const entries = await Promise.all( + kinds.map(async kind => { + const content = await vstorage.read( + `published.agoricNames.${kind}`, + getJSON, + ); + const parts = last(storageNode.unserialize(content, ctx)); + + /** @type {Entry>} */ + const entry = [kind, Object.fromEntries(parts)]; + return entry; + }), + ); + return Object.fromEntries(entries); +}; + +// eslint-disable-next-line no-unused-vars +const examplePurseState = { + brand: { + boardId: 'board0074', + iface: 'Alleged: IST brand', + }, + brandPetname: 'IST', + currentAmount: { + brand: { + kind: 'brand', + petname: 'IST', + }, + value: 125989900, + }, + displayInfo: { + assetKind: 'nat', + decimalPlaces: 6, + }, + pursePetname: 'Agoric stable local currency', +}; +/** @typedef {typeof examplePurseState} PurseState */ + +/** @param {PurseState[]} purses */ +const makeAmountFormatter = purses => amt => { + const { + brand: { petname }, + value, + } = amt; + const purse = purses.find(p => p.brandPetname === petname); + if (!purse) return [NaN, petname]; + const { + brandPetname, + displayInfo: { decimalPlaces }, + } = purse; + /** @type {[qty: number, petname: string]} */ + const scaled = [Number(value) / 10 ** decimalPlaces, brandPetname]; + return scaled; +}; + +const asPercent = ratio => { + const { numerator, denominator } = ratio; + assert(numerator.brand === denominator.brand); + return (100 * Number(numerator.value)) / Number(denominator.value); +}; + +/** @param {PurseState[]} purses */ +const simplePurseBalances = purses => { + const fmt = makeAmountFormatter(purses); + return purses.map(p => fmt(p.currentAmount)); +}; + +/** + * @param {{ purses: PurseState[], offers: OfferDetail[]}} state + * @param {Awaited>} agoricNames + */ +const simpleOffers = (state, agoricNames) => { + const { purses, offers } = state; + const fmt = makeAmountFormatter(purses); + const fmtRecord = r => + Object.fromEntries( + Object.entries(r).map(([kw, { amount }]) => [kw, fmt(amount)]), + ); + return offers.map(o => { + const { + // id, + meta, + instanceHandleBoardId, + invitationDetails: { description: invitationDescription }, + proposalForDisplay: { give, want }, + status, + } = o; + // console.log({ give: JSON.stringify(give), want: JSON.stringify(want) }); + const instanceEntry = Object.entries(agoricNames.instance).find( + ([_name, { boardId }]) => boardId === instanceHandleBoardId, + ); + const instanceName = instanceEntry + ? instanceEntry[0] + : instanceHandleBoardId; + return [ + // id, + meta?.creationStamp ? new Date(meta.creationStamp).toISOString() : null, + status, + instanceName, + invitationDescription, + { + give: fmtRecord(give), + want: fmtRecord(want), + }, + ]; + }); +}; + +const dieTrying = msg => { + throw Error(msg); +}; +/** + * @param {string} addr + * @param {IdMap} ctx + * @param {object} io + * @param {(url: string) => Promise} io.getJSON + */ +const getWalletState = async (addr, ctx, { getJSON }) => { + const txt = await vstorage.read(`published.wallet.${addr}`, getJSON); + /** @type {{ purses: PurseState[], offers: OfferDetail[] }[]} */ + const states = storageNode.unserialize(txt, ctx); + const offerById = new Map(); + states.forEach(state => { + const { offers } = state; + offers.forEach(offer => { + const { id } = offer; + offerById.set(id, offer); + }); + }); + const { purses } = last(states) || dieTrying(); + return { purses, offers: [...offerById.values()] }; +}; + +const getContractState = async (fromBoard, agoricNames, { getJSON }) => { + const govContent = await vstorage.read( + 'published.psm.IST.AUSD.governance', + getJSON, + ); + const { current: governance } = last( + storageNode.unserialize(govContent, fromBoard), + ); + const { + instance: { psm: instance }, + } = agoricNames; + + return { instance, governance }; +}; + +// lines starting with 'export ' are stripped for use in Google Apps Scripts +export { assert, asPercent }; +export { getContractState, getWalletState, simpleOffers, simplePurseBalances }; +export { makeAgoricNames, makeFromBoard, makePSMSpendAction }; +export { networks, vstorage };