Skip to content

Instantly share code, notes, and snippets.

@dckc

dckc/psm-tool Secret

Created September 10, 2022 04:50
Show Gist options
  • Save dckc/8b5b2f16395cb4d7f2ff340e0bc6b610 to your computer and use it in GitHub Desktop.
Save dckc/8b5b2f16395cb4d7f2ff340e0bc6b610 to your computer and use it in GitHub Desktop.
psm-tool with set offer id, find-offer by id feature
#!/usr/bin/env node
/* global process, fetch */
// @ts-check
// @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]
* @returns {asserts cond}
*/
const assert = (cond, msg = undefined) => {
if (!cond) {
throw typeof msg === 'string' ? Error(msg || 'check failed') : msg;
}
return undefined;
};
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<Keyword,Amount>} AmountKeywordRecord
* @typedef {string} Keyword
* @typedef {Partial<ProposalRecord>} 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<T>} WalletCapData<T> */
/**
* @param {Record<string, Brand>} brands
* @param {({ wantMinted: string } | { giveMinted: string })} opts
* @param {number} [fee=0]
* @param {string} [anchor]
* @returns {ProposalRecord}
*/
const makePSMProposal = (brands, opts, fee = 0, anchor = 'AUSD') => {
const brand =
'wantMinted' in opts
? { in: brands[anchor], out: brands.IST }
: { in: brands.IST, out: brands[anchor] };
const value =
Number('wantMinted' in opts ? opts.wantMinted : opts.giveMinted) *
Number(COSMOS_UNIT);
const adjusted = {
in: BigInt(Math.ceil('wantMinted' in opts ? value / (1 - fee) : value)),
out: BigInt(Math.ceil('giveMinted' in opts ? value * (1 - fee) : value)),
};
return {
give: {
In: { brand: brand.in, value: adjusted.in },
},
want: {
Out: { brand: brand.out, value: adjusted.out },
},
};
};
/**
* @param {Record<string, Brand>} brands
* @param {unknown} instance
* @param {{ feePct?: string } &
* ({ wantMinted: string } | { giveMinted: string })} opts
* @param {number} timeStamp
* @returns {BridgeAction}
*/
const makePSMSpendAction = (instance, brands, opts, timeStamp) => {
const method =
'wantMinted' in opts
? 'makeWantMintedInvitation'
: 'makeGiveMintedInvitation'; // 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', height = 0 } = {}) =>
`/abci_query?path=%22/custom/vstorage/${kind}/${path}%22&height=${height}`,
decode: ({ result: { response } }) => {
const { code } = response;
if (code !== 0) {
throw response;
}
const { value } = response;
return atob(value);
},
/**
* @param {string} path
* @param {(url: string) => Promise<any>} getJSON
*/
read: async (path = 'published', getJSON) => {
const raw = await getJSON(vstorage.url(path, { kind: 'data' }));
return vstorage.decode(raw);
},
/**
* @param {string} path
* @param {*} getJSON
* @param {number} [height]
* @returns {Promise<{blockHeight: number, values: string[]}>}
*/
readAt: async (path, getJSON, height = undefined) => {
const raw = await getJSON(vstorage.url(path, { kind: 'data', height }));
const txt = vstorage.decode(raw);
/** @type {{ value: string }} */
const { value } = JSON.parse(txt);
return JSON.parse(value);
},
readAll: async (path, getJSON) => {
const parts = [];
let blockHeight;
do {
let values;
try {
({ blockHeight, values } = await vstorage.readAt(
path,
getJSON,
blockHeight && blockHeight - 1,
));
} catch (err) {
if ('log' in err && err.log.match(/unknown request/)) {
break;
}
throw err;
}
// console.debug(blockHeight, values.length);
parts.push(values);
} while (blockHeight > 0);
return parts.flat();
},
};
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<typeof makeFromBoard>} 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);
const { blockHeight } = specimen;
const capDatas = storageNode.parseMany(specimen.values);
return { blockHeight, capDatas };
},
unserialize: (txt, ctx) => {
const { capDatas } = storageNode.parseCapData(txt);
return capDatas.map(capData =>
miniMarshal(ctx.convertSlotToVal).unserialze(capData),
);
},
parseMany: values => {
/** @type {{ body: string, slots: string[] }[]} */
const capDatas = values.map(s => JSON.parse(s));
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;
},
};
/**
* @template K, V
* @typedef {[key: K, val: V]} Entry<K,V>
*/
const last = xs => xs[xs.length - 1];
/**
* @param {IdMap} ctx
* @param {(url: string) => Promise<any>} 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<string, Record<string, any>>} */
const entry = [kind, Object.fromEntries(parts)];
return entry;
}),
);
return Object.fromEntries(entries);
};
// eslint-disable-next-line no-unused-vars
const exampleAsset = {
brand: { boardId: 'board0425', iface: 'Alleged: BLD brand' },
displayInfo: { assetKind: 'nat', decimalPlaces: 6 },
issuer: { boardId: null, iface: undefined },
petname: 'Agoric staking token',
};
/** @typedef {typeof exampleAsset} AssetDescriptor */
/** @param {AssetDescriptor[]} assets */
const makeAmountFormatter = assets => amt => {
const {
brand: { boardId },
value,
} = amt;
const asset = assets.find(a => a.brand.boardId === boardId);
if (!asset) return [NaN, boardId];
const {
petname,
displayInfo: { assetKind, decimalPlaces },
} = asset;
if (assetKind !== 'nat') return [['?'], petname];
/** @type {[qty: number, petname: string]} */
const scaled = [Number(value) / 10 ** decimalPlaces, petname];
return scaled;
};
const asPercent = ratio => {
const { numerator, denominator } = ratio;
assert(numerator.brand === denominator.brand);
return (100 * Number(numerator.value)) / Number(denominator.value);
};
/**
* @param {Amount[]} balances
* @param {AssetDescriptor[]} assets
*/
const simplePurseBalances = (balances, assets) => {
const fmt = makeAmountFormatter(assets);
return balances.map(b => fmt(b));
};
/**
* @param {{ assets: AssetDescriptor[], offers: Map<number,OfferSpec>}} state
* @param {Awaited<ReturnType<typeof makeAgoricNames>>} agoricNames
*/
const simpleOffers = (state, agoricNames) => {
const { assets, offers } = state;
const fmt = makeAmountFormatter(assets);
const fmtRecord = r =>
Object.fromEntries(
Object.entries(r).map(([kw, amount]) => [kw, fmt(amount)]),
);
return [...offers.keys()].sort().map(id => {
const o = offers.get(id);
assert(o);
assert(o.invitationSpec.source === 'contract');
const {
invitationSpec: { instance, publicInvitationMaker },
proposal: { give, want },
payouts,
} = o;
const entry = Object.entries(agoricNames.instance).find(
([_name, candidate]) => candidate === instance,
);
const instanceName = entry ? entry[0] : '???';
// console.log({ give: JSON.stringify(give), want: JSON.stringify(want) });
return [
instanceName,
new Date(id).toISOString(),
id,
publicInvitationMaker,
o.numWantsSatisfied,
{
give: fmtRecord(give),
want: fmtRecord(want),
...(payouts ? { payouts: fmtRecord(payouts) } : {}),
},
];
});
};
const dieTrying = msg => {
throw Error(msg);
};
/**
* @param {string} addr
* @param {IdMap} ctx
* @param {object} io
* @param {(url: string) => Promise<any>} io.getJSON
*/
const getWalletState = async (addr, ctx, { getJSON }) => {
const values = await vstorage.readAll(`published.wallet.${addr}`, getJSON);
const capDatas = storageNode.parseMany(values);
/** @type {Map<number, OfferSpec>} */
const offers = new Map();
/** @type {Map<Brand, Amount>} */
const balances = new Map();
/** @type {AssetDescriptor[]} */
const assets = [];
const mm = miniMarshal(ctx.convertSlotToVal);
capDatas.forEach(capData => {
const update = mm.unserialze(capData);
switch (update.updated) {
case 'offerStatus': {
const { status } = update;
if (!offers.has(status.id)) {
offers.set(status.id, status);
}
break;
}
case 'balance': {
const { currentAmount } = update;
if (!balances.has(currentAmount.brand)) {
balances.set(currentAmount.brand, currentAmount);
}
break;
}
case 'brand': {
assets.push(update.descriptor);
break;
}
default:
throw Error(update.updated);
}
});
return { balances, assets, offers };
};
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 { 'psm.IST.AUSD': instance } = agoricNames.instance;
return { instance, governance };
};
// 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 {{net?: string}} opts
* @param {object} io
* @param {typeof fetch} io.fetch
*/
const makeTool = async (opts, { fetch }) => {
const net = networks[opts.net || 'local'];
assert(net, opts.net);
const getJSON = async url => (await fetch(log('url')(net.rpc + url))).json();
const showPublishedChildren = async () => {
// 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);
const showContractId = async showFees => {
const { instance, governance } = await getContractState(
fromBoard,
agoricNames,
{
getJSON,
},
);
showFees && console.error('psm', instance, Object.keys(governance));
showFees &&
console.error(
'WantMintedFee',
asPercent(governance.WantMintedFee.value),
'%',
'GiveMintedFee',
asPercent(governance.GiveMintedFee.value),
'%',
);
console.info(instance.boardId);
};
const showWallet = async addr => {
const state = await getWalletState(addr, fromBoard, {
getJSON,
});
const { assets, balances } = state;
// console.log(JSON.stringify(offers, null, 2));
// console.log(JSON.stringify({ offers, purses }, bigIntReplacer, 2));
const summary = {
balances: simplePurseBalances([...balances.values()], assets),
offers: simpleOffers(state, agoricNames),
};
console.log(fmtRecordOfLines(summary));
return 0;
};
const findOffer = async (addr, id) => {
const { assets, offers } = await getWalletState(addr, fromBoard, {
getJSON,
});
const offer = offers.get(id);
if (!offer) {
return 1;
}
const { numWantsSatisfied, payouts } = offer;
const fmt = makeAmountFormatter(assets);
const fmtRecord = r =>
Object.fromEntries(
Object.entries(r).map(([kw, amount]) => [kw, fmt(amount)]),
);
console.error({
numWantsSatisfied,
...(payouts ? { payouts: fmtRecord(payouts) } : {}),
});
return typeof numWantsSatisfied === 'number' ? 0 : 1;
};
const showOffer = id => {
assert(net, opts.net);
const instance = agoricNames.instance['psm-IST-AUSD'];
const spendAction = makePSMSpendAction(
instance,
agoricNames.brand,
// @ts-expect-error
opts,
id,
);
console.log(JSON.stringify(miniMarshal().serialize(spendAction)));
};
return {
publishedChildren: showPublishedChildren,
showContractId,
showOffer,
showWallet,
findOffer,
};
};
// lines starting with 'export ' are stripped for use in Google Apps Scripts
{ assert };
{ networks, makeTool };
const USAGE = `
Usage:
to get contract instance boardId and, optionally, fees
psm-tool --contract [--verbose]
to write an offer to stdout
psm-tool --wantMinted ANCHOR_TOKENS --boardId BOARD_ID [--feePct PCT]
psm-tool --giveMinted 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 --wantMinted 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<string, string>} */
const opts = {};
/** @type {Record<string, boolean>} */
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 };
};
/**
* @param {string[]} argv
* @param {object} io
* @param {typeof fetch} [io.fetch]
* @param {() => Date} io.clock
*/
const main = async (argv, { fetch, clock }) => {
assert(fetch, 'missing fetch API; try --experimental-fetch?');
const { opts, flags } = parseArgs(argv, ['--contract', '--verbose']);
const tool = await makeTool(opts, { fetch });
if (flags.contract) {
await tool.showContractId(flags.verbose);
return 0;
}
if (opts.wallet) {
if (opts.offer) {
return tool.findOffer(opts.wallet, Number(opts.offer));
} else {
return tool.showWallet(opts.wallet);
}
}
if (!(opts.wantMinted || opts.giveMinted)) {
console.error(USAGE);
return 1;
}
const id = 'id' in opts ? Number(opts.id) : clock().getTime();
await tool.showOffer(id);
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),
);
@dckc
Copy link
Author

dckc commented Feb 1, 2023

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment