diff --git a/a3p-integration/proposals/n:upgrade-next/submitBid.js b/a3p-integration/proposals/n:upgrade-next/submitBid.js new file mode 100644 index 00000000000..5ac4c1be879 --- /dev/null +++ b/a3p-integration/proposals/n:upgrade-next/submitBid.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +import { + GOV1ADDR, + CHAINID, + agd, + agopsInter, + addUser, + waitForBlock, + provisionSmartWallet, + ATOM_DENOM, + VALIDATORADDR, +} from '@agoric/synthetic-chain'; + +/** + * + * @param {string} addr + * @param {string} wanted + * @param {string | undefined} from + */ +export const bankSend = (addr, wanted, from = VALIDATORADDR) => { + const chain = ['--chain-id', CHAINID]; + const fromArg = ['--from', from]; + const testKeyring = ['--keyring-backend', 'test']; + const noise = [...fromArg, ...chain, ...testKeyring, '--yes']; + + return agd.tx('bank', 'send', from, addr, wanted, ...noise); +}; + +const bidder = await addUser('long-living-bidder'); +console.log('BIDDER', bidder); +await bankSend(bidder, `80000000uist`, GOV1ADDR); +console.log('IST sent'); +await provisionSmartWallet(bidder, `20000000ubld,100000000${ATOM_DENOM}`); +console.log('Provision sent'); +await waitForBlock(3); +console.log('Wait For Block done. Sending bid offer'); +agopsInter( + 'bid', + 'by-price', + `--price 49.0`, + `--give 80IST`, + '--from', + bidder, + '--keyring-backend test', + `--offer-id long-living-bid-for-acceptance`, +); diff --git a/a3p-integration/proposals/n:upgrade-next/tsconfig.json b/a3p-integration/proposals/n:upgrade-next/tsconfig.json index 39de5a422e9..835db5a0316 100644 --- a/a3p-integration/proposals/n:upgrade-next/tsconfig.json +++ b/a3p-integration/proposals/n:upgrade-next/tsconfig.json @@ -1,7 +1,8 @@ { "compilerOptions": { "target": "esnext", - "module": "esnext", + "module": "NodeNext", + "moduleResolution": "NodeNext", "allowJs": true, "checkJs": true, "strict": false, diff --git a/a3p-integration/proposals/n:upgrade-next/use.sh b/a3p-integration/proposals/n:upgrade-next/use.sh index fa6c4e42f65..129c3b92444 100644 --- a/a3p-integration/proposals/n:upgrade-next/use.sh +++ b/a3p-integration/proposals/n:upgrade-next/use.sh @@ -8,3 +8,4 @@ node ./addGov4 ./verifyPushedPrice.js 'ATOM' 12.01 ./verifyPushedPrice.js 'stATOM' 12.01 +./submitBid.js diff --git a/a3p-integration/proposals/z:acceptance/auction.test.js b/a3p-integration/proposals/z:acceptance/auction.test.js new file mode 100644 index 00000000000..eda93ca260c --- /dev/null +++ b/a3p-integration/proposals/z:acceptance/auction.test.js @@ -0,0 +1,220 @@ +/* eslint-env node */ +/** + * @file In this file we aim to test auctioneer in an isolated manner. Here's the scenario to test; + * + * - Prerequisites: In one of the earlier proposal(n:upgrade-next), a user called "long-living-bidder" + * has placed a bid where { give: 80IST, price: 49.0 } + * - Push price so that 1 ATOM is 50 ISTs + * - Wait until the auctioneer captures the price we just pushed + * - Fund actors + * - gov1 gets 100 ATOMs + * - user1 gets 90 ISTs + * - gov3 gets 150 ISTs + * - Place bids for user1 and gov3 following the values in "config" + * - Deposit 100 ATOMs into book0, gov1 is the depositor + * - Wait until placed bids get their payouts + * - Wait until proceeds are distributed to the depositor + * - Make sure all actors receive the correct payouts + */ + +/** @typedef {import('./test-lib/sync-tools.js').RetryOptions} RetryOptions */ + +import { + agd, + agoric, + getUser, + GOV1ADDR, + GOV3ADDR, + USER1ADDR, +} from '@agoric/synthetic-chain'; +import '@endo/init'; +import test from 'ava'; +import { boardSlottingMarshaller, makeFromBoard } from './test-lib/rpc.js'; +import { retryUntilCondition } from './test-lib/sync-tools.js'; +import { + calculateRetryUntilNextStartTime, + checkBidsOutcome, + checkDepositOutcome, + checkPriceCaptured, + depositCollateral, + fundAccts, + placeBids, + pushPricesForAuction, + scale6, +} from './test-lib/auction-lib.js'; + +const ambientAuthority = { + query: agd.query, + follow: agoric.follow, + setTimeout, +}; + +const fromBoard = makeFromBoard(); +const marshaller = boardSlottingMarshaller(fromBoard.convertSlotToVal); + +const config = { + depositor: { + name: 'gov1', + addr: GOV1ADDR, + depositValue: '100000000', + offerId: `gov1-deposit-${Date.now()}`, + }, + price: 50.0, + longLivingBidSetup: { + name: 'long-living-bidder', + // This bid is placed in an earlier proposal + give: '80IST', + }, + currentBidsSetup: { + user1: { + bidder: USER1ADDR, + bidderFund: { + value: 90000000, + denom: 'uist', + }, + offerId: `user1-bid-${Date.now()}`, + give: '90IST', + price: 46, + }, + gov3: { + bidder: GOV3ADDR, + bidderFund: { + value: 150000000, + denom: 'uist', + }, + offerId: `gov3-bid-${Date.now()}`, + give: '150IST', + discount: '13', + }, + }, + bidsOutcome: { + longLivingBidder: { + payouts: { + Bid: 0, + Collateral: 1.68421, + }, + }, + user1: { + payouts: { + Bid: 0, + Collateral: 2.0, + }, + }, + gov3: { + payouts: { + Bid: 0, + Collateral: 3.448275, + }, + }, + }, +}; + +test.before(async t => { + /** @type {RetryOptions} */ + const pushPriceRetryOpts = { + maxRetries: 5, // arbitrary + retryIntervalMs: 5000, // in ms + }; + + /** @type {RetryOptions} */ + const bankSendRetryOpts = { + maxRetries: 3, // arbitrary + retryIntervalMs: 3000, // in ms + }; + + // Get current round id + const round = await agoric.follow( + '-lF', + ':published.priceFeed.ATOM-USD_price_feed.latestRound', + ); + t.context = { + roundId: parseInt(round.roundId, 10), + retryOpts: harden({ + bankSendRetryOpts, + pushPriceRetryOpts, + }), + }; +}); + +test('run auction', async t => { + // Push the price to a point where only our bids can settle + await pushPricesForAuction(t, config.price); + + // Wait until next round starts. Retry error message is useful for debugging + const { retryOptions, nextStartTime } = + await calculateRetryUntilNextStartTime(); + await retryUntilCondition( + () => Promise.resolve(Date.now()), + res => res >= nextStartTime * 1000, + 'next auction round not started yet [AUCTION TEST]', + { + log: t.log, + ...ambientAuthority, + ...retryOptions, + }, + ); + + // Make sure depositor and bidders have enough balance + await fundAccts(t, config.depositor, config.currentBidsSetup); + const bidsP = placeBids(t, config.currentBidsSetup); + const proceedsP = depositCollateral(t, config.depositor); + + // Make sure price captured + // We have to check this after bids are placed and collateral deposited + // because of https://github.com/Agoric/BytePitchPartnerEng/issues/31 + await checkPriceCaptured('book0', scale6(config.price).toString()); + + // Resolves when auction finalizes and depositor gets payouts + const [longLivingBidderAddr] = await Promise.all([ + getUser(config.longLivingBidSetup.name), + ...bidsP, + proceedsP, + ]); + + // Query wallets of the actors involved for assertions + const [gov1Results, longLivingBidResults, user1Results, gov3Results, brands] = + await Promise.all([ + agoric + .follow( + '-lF', + `:published.wallet.${config.depositor.addr}`, + '-o', + 'text', + ) + .then(res => marshaller.fromCapData(JSON.parse(res))), + agoric + .follow( + '-lF', + `:published.wallet.${longLivingBidderAddr}`, + '-o', + 'text', + ) + .then(res => marshaller.fromCapData(JSON.parse(res))), + agoric + .follow('-lF', `:published.wallet.${USER1ADDR}`, '-o', 'text') + .then(res => marshaller.fromCapData(JSON.parse(res))), + agoric + .follow('-lF', `:published.wallet.${GOV3ADDR}`, '-o', 'text') + .then(res => marshaller.fromCapData(JSON.parse(res))), + agoric + .follow('-lF', ':published.agoricNames.brand', '-o', 'text') + .then(res => + Object.fromEntries(marshaller.fromCapData(JSON.parse(res))), + ), + ]); + + // Assert depositor paid correctly + checkDepositOutcome(t, gov1Results.status.payouts, config, brands); + + // Assert bidders paid correctly + checkBidsOutcome( + t, + { + 'longLivingBidder.results': longLivingBidResults, + 'user1.results': user1Results, + 'gov3.results': gov3Results, + }, + config.bidsOutcome, + brands, + ); +}); diff --git a/a3p-integration/proposals/z:acceptance/scripts/test-vaults.mts b/a3p-integration/proposals/z:acceptance/scripts/test-vaults.mts index 7fdbcef510a..6aa5cf15577 100755 --- a/a3p-integration/proposals/z:acceptance/scripts/test-vaults.mts +++ b/a3p-integration/proposals/z:acceptance/scripts/test-vaults.mts @@ -13,7 +13,7 @@ import { ISTunit, provisionWallet, setDebtLimit, -} from '../lib/vaults.mjs'; +} from '../test-lib/vaults.mjs'; const START_FREQUENCY = 600; // StartFrequency: 600s (auction runs every 10m) const CLOCK_STEP = 20; // ClockStep: 20s (ensures auction completes in time) diff --git a/a3p-integration/proposals/z:acceptance/test-lib/auction-lib.js b/a3p-integration/proposals/z:acceptance/test-lib/auction-lib.js new file mode 100644 index 00000000000..9ce1e3ddee1 --- /dev/null +++ b/a3p-integration/proposals/z:acceptance/test-lib/auction-lib.js @@ -0,0 +1,311 @@ +/* eslint-env node */ +import { + agd, + agopsInter, + agoric, + ATOM_DENOM, + CHAINID, + executeOffer, + generateOracleMap, + getPriceQuote, + GOV1ADDR, + pushPrices, + VALIDATORADDR, +} from '@agoric/synthetic-chain'; +import { AmountMath } from '@agoric/ertp'; +import { + retryUntilCondition, + waitUntilAccountFunded, + waitUntilOfferResult, +} from './sync-tools.js'; +import { boardSlottingMarshaller, makeFromBoard } from './rpc.js'; + +/** + * Typo will be fixed with https://github.com/Agoric/agoric-sdk/pull/10171 + * @typedef {import('./sync-tools.js').RetryOptions} RetryOptions + */ + +const ambientAuthority = { + query: agd.query, + follow: agoric.follow, + setTimeout, +}; + +export const scale6 = x => BigInt(x * 1_000_000); + +const fromBoard = makeFromBoard(); +const marshaller = boardSlottingMarshaller(fromBoard.convertSlotToVal); + +// From n-upgrade/verifyPushedPrice.js +const BASE_ID = 'n-upgrade'; + +// Import from synthetic-chain once it is updated +export const bankSend = (from, addr, wanted) => { + const chain = ['--chain-id', CHAINID]; + const fromArg = ['--from', from]; + const testKeyring = ['--keyring-backend', 'test']; + const noise = [...fromArg, ...chain, ...testKeyring, '--yes']; + + return agd.tx('bank', 'send', from, addr, wanted, ...noise); +}; + +export const pushPricesForAuction = async (t, price) => { + const oraclesByBrand = generateOracleMap(BASE_ID, ['ATOM']); + + await pushPrices(price, 'ATOM', oraclesByBrand, t.context.roundId + 1); + + await retryUntilCondition( + () => getPriceQuote('ATOM'), + res => res === `+${scale6(price).toString()}`, + 'price not pushed yet', + { + log: t.log, + setTimeout, + ...t.context.pushPriceRetryOpts, + }, + ); +}; + +/** + * @param {any} t + * @param {{ + * name: string + * offerId: string, + * depositValue: string, + * }} depositor + * @param {Record} bidders + */ +export const fundAccts = async (t, depositor, bidders) => { + const retryOpts = t.context.retryOpts.bankSendRetryOpts; + + await bankSend( + VALIDATORADDR, + GOV1ADDR, + `${depositor.depositValue}${ATOM_DENOM}`, + ); + await waitUntilAccountFunded( + GOV1ADDR, + ambientAuthority, + { denom: ATOM_DENOM, value: Number(depositor.depositValue) }, + { errorMessage: `${depositor.name} not funded yet`, ...retryOpts }, + ); + + for await (const [key, value] of [...Object.entries(bidders)]) { + const fund = value.bidderFund; + await bankSend(GOV1ADDR, value.bidder, `${fund.value}${fund.denom}`); + await waitUntilAccountFunded(value.bidder, ambientAuthority, fund, { + errorMessage: `${key} not funded yet`, + ...retryOpts, + }); + } +}; + +export const bidByPrice = (price, give, offerId, bidder) => { + return agopsInter( + 'bid', + 'by-price', + `--price ${price}`, + `--give ${give}`, + '--from', + bidder, + '--keyring-backend test', + `--offer-id ${offerId}`, + ); +}; + +export const bidByDiscount = (discount, give, offerId, bidder) => { + return agopsInter( + 'bid', + 'by-discount', + `--discount ${discount}`, + `--give ${give}`, + '--from', + bidder, + '--keyring-backend test', + `--offer-id ${offerId}`, + ); +}; + +export const placeBids = (t, bidsSetup) => { + return [...Object.values(bidsSetup)].map( + ({ bidder, offerId, price, give, discount }) => { + if (price) return bidByPrice(price, give, offerId, bidder); + return bidByDiscount(discount, give, offerId, bidder); + }, + ); +}; + +/** + * Calculates retry options based on "nextStartTime" + */ +export const calculateRetryUntilNextStartTime = async () => { + const schedule = await agoric.follow('-lF', ':published.auction.schedule'); + const nextStartTime = parseInt(schedule.nextStartTime.absValue, 10); + + /** @type {RetryOptions} */ + const capturePriceRetryOpts = { + maxRetries: Math.ceil((nextStartTime * 1000 - Date.now()) / 10000) + 2, // wait until next schedule + retryIntervalMs: 10000, // 10 seconds in ms + }; + + return { retryOptions: capturePriceRetryOpts, nextStartTime }; +}; + +/** + * + * @param {any} t + * @param {{ + * offerId: string, + * depositValue: string, + * addr: string + * }} depositor + */ +export const depositCollateral = async (t, depositor) => { + const [brandsRaw, { retryOptions }] = await Promise.all([ + agoric.follow('-lF', ':published.agoricNames.brand', '-o', 'text'), + calculateRetryUntilNextStartTime(), + ]); + const brands = Object.fromEntries( + marshaller.fromCapData(JSON.parse(brandsRaw)), + ); + + const offerSpec = { + id: depositor.offerId, + invitationSpec: { + source: 'agoricContract', + instancePath: ['auctioneer'], + callPipe: [['makeDepositInvitation']], + }, + proposal: { + give: { + Collateral: { + brand: brands.ATOM, + value: BigInt(depositor.depositValue), + }, + }, + }, + }; + + const spendAction = { + method: 'executeOffer', + offer: offerSpec, + }; + + const offer = JSON.stringify(marshaller.toCapData(harden(spendAction))); + t.log('OFFER', offer); + + executeOffer(depositor.addr, offer); + return waitUntilOfferResult( + depositor.addr, + depositor.offerId, + true, + ambientAuthority, + { + errorMessage: 'proceeds not distributed yet', + ...retryOptions, + }, + ); +}; + +export const checkBidsOutcome = (t, settledBids, bidsOutcome, brands) => { + [...Object.entries(settledBids)] + .map(([key, bidResult]) => [key.split('.')[0], bidResult.status.payouts]) + .forEach(([key, { Bid, Collateral }]) => { + t.log({ bidsOutcome }); + const { + payouts: { Bid: outcomeBidVal, Collateral: outcomeColVal }, + } = bidsOutcome[key]; + t.log({ outcomeBidVal, outcomeColVal }); + t.is( + AmountMath.isEqual( + Bid, + AmountMath.make(brands.IST, scale6(outcomeBidVal)), + ), + true, + ); + t.is( + AmountMath.isGTE( + Collateral, + AmountMath.make(brands.ATOM, scale6(outcomeColVal)), + ), + true, + ); + }); +}; + +export const checkDepositOutcome = (t, depositorPayouts, config, brands) => { + // Assert depositor paid correctly + const { Bid: depositorBid, Collateral: depositorCol } = depositorPayouts; + const { + depositor, + longLivingBidSetup: { give: longLivingBidGive }, + currentBidsSetup, + bidsOutcome, + } = config; + + const getNumberFromGive = give => + parseInt(give.substring(0, give.length - 3), 10); + + const calculateGiveTotal = () => { + let currentBidSum = getNumberFromGive(longLivingBidGive); + [...Object.values(currentBidsSetup)].forEach(({ give }) => { + currentBidSum += getNumberFromGive(give); + }); + + return scale6(currentBidSum); + }; + + const calculateOutcomeTotal = () => { + let total = 0n; + [...Object.values(bidsOutcome)] + .map(outcome => outcome.payouts.Collateral) + .forEach(element => { + t.log(element); + total += scale6(element); + }); + + return total; + }; + + t.is( + AmountMath.isEqual( + depositorBid, + AmountMath.make(brands.IST, calculateGiveTotal()), + ), + true, + ); + t.is( + AmountMath.isGTE( + AmountMath.make( + brands.ATOM, + BigInt(depositor.depositValue) - calculateOutcomeTotal(), + ), + depositorCol, + ), + true, + ); +}; + +/** + * + * @param {string} bookId + * @param {string} expected + */ +export const checkPriceCaptured = async (bookId, expected) => { + const { + startPrice: { + numerator: { value }, + }, + } = await agoric.follow('-lF', `:published.auction.${bookId}`); + assert(value, expected); +}; diff --git a/a3p-integration/proposals/z:acceptance/test-lib/auction-lib.test.js b/a3p-integration/proposals/z:acceptance/test-lib/auction-lib.test.js new file mode 100644 index 00000000000..1fa71951158 --- /dev/null +++ b/a3p-integration/proposals/z:acceptance/test-lib/auction-lib.test.js @@ -0,0 +1,122 @@ +import '@endo/init'; +import { AmountMath } from '@agoric/ertp'; +import { Far } from '@endo/far'; +import test from 'ava'; +import { GOV3ADDR, USER1ADDR } from '@agoric/synthetic-chain'; +import { checkBidsOutcome, checkDepositOutcome } from './auction-lib.js'; + +// From auction.test.js +const config = { + depositor: { + name: 'gov1', + depositValue: '100000000', + offerId: `gov1-deposit-${Date.now()}`, + }, + longLivingBidSetup: { + name: 'long-living-bidder', + // This bid is placed in an earlier proposal + give: '80IST', + }, + currentBidsSetup: { + user1: { + bidder: USER1ADDR, + bidderFund: { + value: 90000000, + denom: 'uist', + }, + offerId: `user1-bid-${Date.now()}`, + give: '90IST', + price: 46, + }, + gov3: { + bidder: GOV3ADDR, + bidderFund: { + value: 150000000, + denom: 'uist', + }, + offerId: `gov3-bid-${Date.now()}`, + give: '150IST', + discount: '13', + }, + }, + bidsOutcome: { + longLivingBidder: { + payouts: { + Bid: 0, + Collateral: 1.68421, + }, + }, + user1: { + payouts: { + Bid: 0, + Collateral: 2.0, + }, + }, + gov3: { + payouts: { + Bid: 0, + Collateral: 3.448275, + }, + }, + }, +}; + +test.before(t => { + const mockIST = Far('IST', {}); + const mockATOM = Far('ATOM', {}); + + t.context = { + brands: { + IST: mockIST, + ATOM: mockATOM, + }, + }; +}); + +test('make sure check* functions work properly', t => { + // @ts-expect-error + const { brands } = t.context; + const result = { + status: { + payouts: { + Bid: AmountMath.make(brands.IST, 0n), + Collateral: AmountMath.make(brands.ATOM, 1684210n), + }, + }, + }; + + checkBidsOutcome( + t, + { + 'longLivingBidder.results': result, + 'user1.results': { + status: { + payouts: { + Bid: AmountMath.make(brands.IST, 0n), + Collateral: AmountMath.make(brands.ATOM, 2000000n), + }, + }, + }, + 'gov3.results': { + status: { + payouts: { + Bid: AmountMath.make(brands.IST, 0n), + Collateral: AmountMath.make(brands.ATOM, 3448275n), + }, + }, + }, + }, + config.bidsOutcome, + brands, + ); + + checkDepositOutcome( + t, + harden({ + Bid: AmountMath.make(brands.IST, 320000000n), + Collateral: AmountMath.make(brands.ATOM, 100_000_000n - 7_132_485n), + }), + config, + brands, + ); +}); diff --git a/a3p-integration/proposals/z:acceptance/lib/vaults.mts b/a3p-integration/proposals/z:acceptance/test-lib/vaults.mts similarity index 100% rename from a3p-integration/proposals/z:acceptance/lib/vaults.mts rename to a3p-integration/proposals/z:acceptance/test-lib/vaults.mts diff --git a/a3p-integration/proposals/z:acceptance/test.sh b/a3p-integration/proposals/z:acceptance/test.sh index ee4e473496d..7f33803cfbd 100755 --- a/a3p-integration/proposals/z:acceptance/test.sh +++ b/a3p-integration/proposals/z:acceptance/test.sh @@ -5,7 +5,7 @@ set -ueo pipefail # The effects of this step are not persisted in further proposal layers. # test the state right after the previous proposals -yarn ava initial.test.js +# yarn ava initial.test.js # XXX some of these tests have path dependencies so no globs yarn ava core-eval.test.js @@ -29,6 +29,9 @@ yarn ava wallet.test.js echo ACCEPTANCE TESTING vaults yarn ava vaults.test.js +echo ACCEPTANCE TESTING auction +yarn ava auction.test.js + echo ACCEPTANCE TESTING psm yarn ava psm.test.js