From 1d60661908b457ddcaecf20a40cc5ae24344a537 Mon Sep 17 00:00:00 2001 From: Jorge-Lopes Date: Fri, 18 Oct 2024 18:35:46 +0100 Subject: [PATCH] chore(a3p): create helper functions for Vaults tests rel: https://github.com/Agoric/BytePitchPartnerEng/issues/22 --- .../z:acceptance/test-lib/price-feed.js | 62 +++++ .../proposals/z:acceptance/test-lib/ratio.js | 128 ++++++++++ .../proposals/z:acceptance/test-lib/utils.js | 23 +- .../proposals/z:acceptance/test-lib/vaults.js | 233 ++++++++++++++++++ 4 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 a3p-integration/proposals/z:acceptance/test-lib/price-feed.js create mode 100644 a3p-integration/proposals/z:acceptance/test-lib/ratio.js create mode 100644 a3p-integration/proposals/z:acceptance/test-lib/vaults.js diff --git a/a3p-integration/proposals/z:acceptance/test-lib/price-feed.js b/a3p-integration/proposals/z:acceptance/test-lib/price-feed.js new file mode 100644 index 000000000000..179cfa5c203f --- /dev/null +++ b/a3p-integration/proposals/z:acceptance/test-lib/price-feed.js @@ -0,0 +1,62 @@ +/* eslint-env node */ + +import { + agoric, + getContractInfo, + pushPrices, + getPriceQuote, +} from '@agoric/synthetic-chain'; +import { retryUntilCondition } from './sync-tools.js'; + +export const scale6 = x => BigInt(x * 1_000_000); + +/** + * + * @param {Map} oraclesByBrand + * @param {string} brand + * @param {number} price + * @param {number} roundId + * @returns {Promise} + */ +export const verifyPushedPrice = async ( + oraclesByBrand, + brand, + price, + roundId, +) => { + const pushPriceRetryOpts = { + maxRetries: 5, // arbitrary + retryIntervalMs: 5000, // in ms + }; + + await pushPrices(price, brand, oraclesByBrand, roundId); + console.log(`Pushing price ${price} for ${brand}`); + + await retryUntilCondition( + () => getPriceQuote(brand), + res => res === `+${scale6(price).toString()}`, + 'price not pushed yet', + { + log: console.log, + setTimeout: global.setTimeout, + ...pushPriceRetryOpts, + }, + ); + console.log(`Price ${price} pushed for ${brand}`); +}; + +/** + * + * @param {string} brand + * @returns {Promise} + */ +export const getPriceFeedRoundId = async brand => { + const latestRoundPath = `published.priceFeed.${brand}-USD_price_feed.latestRound`; + const latestRound = await getContractInfo(latestRoundPath, { + agoric, + prefix: '', + }); + + console.log('latestRound: ', latestRound); + return Number(latestRound.roundId); +}; diff --git a/a3p-integration/proposals/z:acceptance/test-lib/ratio.js b/a3p-integration/proposals/z:acceptance/test-lib/ratio.js new file mode 100644 index 000000000000..1197f5383d23 --- /dev/null +++ b/a3p-integration/proposals/z:acceptance/test-lib/ratio.js @@ -0,0 +1,128 @@ +import { AmountMath } from '@agoric/ertp'; +import { q, Fail } from '@endo/errors'; + +/** + * Is `allegedNum` a number in the [contiguous range of exactly and + * unambiguously + * representable](https://esdiscuss.org/topic/more-numeric-constants-please-especially-epsilon#content-14) + * natural numbers (non-negative integers)? + * + * To qualify `allegedNum` must either be a + * non-negative `bigint`, or a non-negative `number` representing an integer + * within range of [integers safely representable in + * floating point](https://tc39.es/ecma262/#sec-number.issafeinteger). + * + * @param {unknown} allegedNum + * @returns {boolean} + */ +function isNat(allegedNum) { + if (typeof allegedNum === 'bigint') { + return allegedNum >= 0; + } + if (typeof allegedNum !== 'number') { + return false; + } + + return Number.isSafeInteger(allegedNum) && allegedNum >= 0; +} + +/** + * If `allegedNumber` passes the `isNat` test, then return it as a bigint. + * Otherwise throw an appropriate error. + * + * If `allegedNum` is neither a bigint nor a number, `Nat` throws a `TypeError`. + * Otherwise, if it is not a [safely + * representable](https://esdiscuss.org/topic/more-numeric-constants-please-especially-epsilon#content-14) + * non-negative integer, `Nat` throws a `RangeError`. + * Otherwise, it is converted to a bigint if necessary and returned. + * + * @param {unknown} allegedNum + * @returns {bigint} + */ +function Nat(allegedNum) { + if (typeof allegedNum === 'bigint') { + if (allegedNum < 0) { + throw RangeError(`${allegedNum} is negative`); + } + return allegedNum; + } + + if (typeof allegedNum === 'number') { + if (!Number.isSafeInteger(allegedNum)) { + throw RangeError(`${allegedNum} is not a safe integer`); + } + if (allegedNum < 0) { + throw RangeError(`${allegedNum} is negative`); + } + return BigInt(allegedNum); + } + + throw TypeError( + `${allegedNum} is a ${typeof allegedNum} but must be a bigint or a number`, + ); +} + +/** + * These operations should be used for calculations with the values of + * basic fungible tokens. + * + * natSafeMath is designed to be used directly, and so it needs to + * validate the inputs, as well as the outputs when necessary. + */ +export const natSafeMath = harden({ + // BigInts don't observably overflow + add: (x, y) => Nat(x) + Nat(y), + subtract: (x, y) => Nat(Nat(x) - Nat(y)), + multiply: (x, y) => Nat(x) * Nat(y), + floorDivide: (x, y) => Nat(x) / Nat(y), + ceilDivide: (x, y) => { + y = Nat(y); + return Nat(Nat(x) + y - 1n) / y; + }, +}); + +const ratioPropertyNames = ['numerator', 'denominator']; + +export const assertIsRatio = ratio => { + const keys = Object.keys(ratio); + keys.length === 2 || Fail`Ratio ${ratio} must be a record with 2 fields.`; + for (const name of keys) { + ratioPropertyNames.includes(name) || + Fail`Parameter must be a Ratio record, but ${ratio} has ${q(name)}`; + } + const numeratorValue = ratio.numerator.value; + const denominatorValue = ratio.denominator.value; + isNat(numeratorValue) || + Fail`The numerator value must be a NatValue, not ${numeratorValue}`; + isNat(denominatorValue) || + Fail`The denominator value must be a NatValue, not ${denominatorValue}`; +}; + +/** + * @param {import('@agoric/ertp/src/types.js').Amount<'nat'>} amount + * @param {*} ratio + * @param {*} divideOp + * @returns {import('@agoric/ertp/src/types.js').Amount<'nat'>} + */ +const multiplyHelper = (amount, ratio, divideOp) => { + AmountMath.coerce(amount.brand, amount); + assertIsRatio(ratio); + amount.brand === ratio.denominator.brand || + Fail`amount's brand ${q(amount.brand)} must match ratio's denominator ${q( + ratio.denominator.brand, + )}`; + + return /** @type {import('@agoric/ertp/src/types.js').Amount<'nat'>} */ ( + AmountMath.make( + ratio.numerator.brand, + divideOp( + natSafeMath.multiply(amount.value, ratio.numerator.value), + ratio.denominator.value, + ), + ) + ); +}; + +export const ceilMultiplyBy = (amount, ratio) => { + return multiplyHelper(amount, ratio, natSafeMath.ceilDivide); +}; diff --git a/a3p-integration/proposals/z:acceptance/test-lib/utils.js b/a3p-integration/proposals/z:acceptance/test-lib/utils.js index 2ea35ba4bae5..3eb5dca0c192 100644 --- a/a3p-integration/proposals/z:acceptance/test-lib/utils.js +++ b/a3p-integration/proposals/z:acceptance/test-lib/utils.js @@ -1,6 +1,8 @@ -import { makeAgd, agops } from '@agoric/synthetic-chain'; +import '@endo/init'; +import { makeAgd, agops, agoric } from '@agoric/synthetic-chain'; import { execFileSync } from 'node:child_process'; import { readFile, writeFile } from 'node:fs/promises'; +import { boardSlottingMarshaller, makeFromBoard } from './rpc.js'; /** * @param {string} fileName base file name without .tjs extension @@ -66,3 +68,22 @@ export const makeTimerUtils = ({ setTimeout }) => { waitUntil, }; }; + +const fromBoard = makeFromBoard(); +const marshaller = boardSlottingMarshaller(fromBoard.convertSlotToVal); + +export const getAgoricNamesBrands = async () => { + const brands = await agoric + .follow('-lF', ':published.agoricNames.brand', '-o', 'text') + .then(res => Object.fromEntries(marshaller.fromCapData(JSON.parse(res)))); + + return brands; +}; + +export const getAgoricNamesInstances = async () => { + const instances = await agoric + .follow('-lF', ':published.agoricNames.instance', '-o', 'text') + .then(res => Object.fromEntries(marshaller.fromCapData(JSON.parse(res)))); + + return instances; +}; diff --git a/a3p-integration/proposals/z:acceptance/test-lib/vaults.js b/a3p-integration/proposals/z:acceptance/test-lib/vaults.js new file mode 100644 index 000000000000..59c0f9fad06d --- /dev/null +++ b/a3p-integration/proposals/z:acceptance/test-lib/vaults.js @@ -0,0 +1,233 @@ +/* eslint-env node */ + +import '@endo/init'; +import { + agops, + agoric, + executeOffer, + getContractInfo, + GOV1ADDR, + GOV2ADDR, + GOV3ADDR, +} from '@agoric/synthetic-chain'; +import { AmountMath } from '@agoric/ertp'; +import { ceilMultiplyBy } from './ratio.js'; +import { getAgoricNamesBrands, getAgoricNamesInstances } from './utils.js'; +import { boardSlottingMarshaller, makeFromBoard } from './rpc.js'; +import { retryUntilCondition } from './sync-tools.js'; + +const fromBoard = makeFromBoard(); +const marshaller = boardSlottingMarshaller(fromBoard.convertSlotToVal); + +/** + * + * @param {string} address + * @returns {Promise<{ vaultID: string, debt: bigint, collateral: bigint, state: string }>} + */ +export const getLastVaultFromAddress = async address => { + const activeVaults = await agops.vaults('list', '--from', address); + const vaultPath = activeVaults[activeVaults.length - 1]; + const vaultID = vaultPath.split('.').pop(); + + if (!vaultID) { + throw new Error(`No vaults found for ${address}`); + } + + const vaultData = await getContractInfo(vaultPath, { agoric, prefix: '' }); + console.log('vaultData: ', vaultData); + + const debt = vaultData.debtSnapshot.debt.value; + const collateral = vaultData.locked.value; + const state = vaultData.vaultState; + + return { vaultID, debt, collateral, state }; +}; + +/** + * + * @param {string} vaultManager + * @returns {Promise<{ availableDebtForMint: bigint, debtLimit: bigint, totalDebt: bigint }>} + */ +export const getAvailableDebtForMint = async vaultManager => { + const governancePath = `published.vaultFactory.managers.${vaultManager}.governance`; + const governance = await getContractInfo(governancePath, { + agoric, + prefix: '', + }); + + const debtLimit = governance.current.DebtLimit.value; + console.log('debtLimit: ', debtLimit.value); + + const metricsPath = `published.vaultFactory.managers.${vaultManager}.metrics`; + const metrics = await getContractInfo(metricsPath, { + agoric, + prefix: '', + }); + + const totalDebt = metrics.totalDebt; + console.log('totalDebt: ', totalDebt.value); + + // @ts-expect-error + const availableDebtForMint = (debtLimit.value - totalDebt.value) / 1_000_000n; + console.log('availableDebtForMint: ', availableDebtForMint); + + return { + availableDebtForMint, + debtLimit: debtLimit.value, + totalDebt: totalDebt.value, + }; +}; + +/** + * + * @returns {Promise} + */ +export const getMinInitialDebt = async () => { + const governancePath = `published.vaultFactory.governance`; + const governance = await getContractInfo(governancePath, { + agoric, + prefix: '', + }); + + const minInitialDebt = governance.current.MinInitialDebt.value.value; + console.log('minInitialDebt: ', minInitialDebt); + + return minInitialDebt / 1_000_000n; +}; + +/** + * + * @param {bigint} toMintValue + * @param {string} vaultManager + * @returns {Promise<{ mintFee: import('@agoric/ertp/src/types.js').NatAmount, adjustedToMintAmount: import('@agoric/ertp/src/types.js').NatAmount }>} + */ +export const calculateMintFee = async (toMintValue, vaultManager) => { + const brands = await getAgoricNamesBrands(); + + const governancePath = `published.vaultFactory.managers.${vaultManager}.governance`; + const governance = await getContractInfo(governancePath, { + agoric, + prefix: '', + }); + + const mintFee = governance.current.MintFee; + const { numerator, denominator } = mintFee.value; + const mintFeeRatio = harden({ + numerator: AmountMath.make(brands.IST, numerator.value), + denominator: AmountMath.make(brands.IST, denominator.value), + }); + + const toMintAmount = AmountMath.make(brands.IST, toMintValue * 1_000_000n); + const expectedMintFee = ceilMultiplyBy(toMintAmount, mintFeeRatio); + const adjustedToMintAmount = AmountMath.add(toMintAmount, expectedMintFee); + + console.log('mintFee: ', mintFee); + console.log('adjustedToMintAmount: ', adjustedToMintAmount); + + return { mintFee, adjustedToMintAmount }; +}; + +const voteForNewParams = (accounts, position) => { + console.log('ACTIONS voting for position', position, 'using', accounts); + return Promise.all( + accounts.map(account => + agops.ec('vote', '--forPosition', position, '--send-from', account), + ), + ); +}; + +const paramChangeOfferGeneration = async ( + previousOfferId, + voteDur, + debtLimit, +) => { + const ISTunit = 1_000_000n; // aka displayInfo: { decimalPlaces: 6 } + + const brand = await getAgoricNamesBrands(); + assert(brand.IST); + assert(brand.ATOM); + + const instance = await getAgoricNamesInstances(); + assert(instance.VaultFactory); + + const voteDurSec = BigInt(voteDur); + const debtLimitValue = BigInt(debtLimit) * ISTunit; + const toSec = ms => BigInt(Math.round(ms / 1000)); + + const id = `propose-${Date.now()}`; + const deadline = toSec(Date.now()) + voteDurSec; + + const body = { + method: 'executeOffer', + offer: { + id, + invitationSpec: { + invitationMakerName: 'VoteOnParamChange', + previousOffer: previousOfferId, + source: 'continuing', + }, + offerArgs: { + deadline, + instance: instance.VaultFactory, + params: { + DebtLimit: { + brand: brand.IST, + value: debtLimitValue, + }, + }, + path: { + paramPath: { + key: { + collateralBrand: brand.ATOM, + }, + }, + }, + }, + proposal: {}, + }, + }; + + return JSON.stringify(marshaller.toCapData(harden(body))); +}; + +export const proposeNewDebtCeiling = async (address, debtLimit) => { + const charterAcceptOfferId = await agops.ec( + 'find-continuing-id', + '--for', + `${'charter\\ member\\ invitation'}`, + '--from', + address, + ); + + return executeOffer( + address, + paramChangeOfferGeneration(charterAcceptOfferId, 30, debtLimit), + ); +}; + +export const setDebtLimit = async (address, debtLimit) => { + const govAccounts = [GOV1ADDR, GOV2ADDR, GOV3ADDR]; + + console.log('ACTIONS Setting debt limit'); + + await proposeNewDebtCeiling(address, debtLimit); + await voteForNewParams(govAccounts, 0); + + console.log('ACTIONS wait for the vote to pass'); + + const pushPriceRetryOpts = { + maxRetries: 10, // arbitrary + retryIntervalMs: 5000, // in ms + }; + + await retryUntilCondition( + () => getAvailableDebtForMint('manager0'), + res => res.debtLimit === debtLimit * 1_000_000n, + 'debt limit not set yet', + { + log: console.log, + setTimeout: global.setTimeout, + ...pushPriceRetryOpts, + }, + ); +};