diff --git a/multichain-testing/test/fast-usdc/fast-usdc.test.ts b/multichain-testing/test/fast-usdc/fast-usdc.test.ts index c86361e20b6..81de8db9128 100644 --- a/multichain-testing/test/fast-usdc/fast-usdc.test.ts +++ b/multichain-testing/test/fast-usdc/fast-usdc.test.ts @@ -1,27 +1,27 @@ import anyTest from '@endo/ses-ava/prepare-endo.js'; -import type { TestFn } from 'ava'; import { encodeAddressHook } from '@agoric/cosmic-proto/address-hooks.js'; +import type { QueryBalanceResponseSDKType } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; import { AmountMath } from '@agoric/ertp'; -import type { Denom } from '@agoric/orchestration'; -import { divideBy, multiplyBy } from '@agoric/zoe/src/contractSupport/ratio.js'; -import type { IBCChannelID } from '@agoric/vats'; -import { makeDoOffer, type WalletDriver } from '../../tools/e2e-tools.js'; -import { makeDenomTools } from '../../tools/asset-info.js'; -import { createWallet } from '../../tools/wallet.js'; -import { makeQueryClient } from '../../tools/query.js'; -import { commonSetup, type SetupContextWithWallets } from '../support.js'; -import { makeFeedPolicyPartial, oracleMnemonics } from './config.js'; -import { makeRandomDigits } from '../../tools/random.js'; -import { makeTracer } from '@agoric/internal'; +import type { USDCProposalShapes } from '@agoric/fast-usdc/src/pool-share-math.js'; import type { CctpTxEvidence, EvmAddress, PoolMetrics, } from '@agoric/fast-usdc/src/types.js'; +import { makeTracer } from '@agoric/internal'; +import type { Denom } from '@agoric/orchestration'; import type { CurrentWalletRecord } from '@agoric/smart-wallet/src/smartWallet.js'; -import type { QueryBalanceResponseSDKType } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; -import type { USDCProposalShapes } from '@agoric/fast-usdc/src/pool-share-math.js'; +import type { IBCChannelID } from '@agoric/vats'; +import { divideBy, multiplyBy } from '@agoric/zoe/src/contractSupport/ratio.js'; +import type { TestFn } from 'ava'; +import { makeDenomTools } from '../../tools/asset-info.js'; +import { makeDoOffer, type WalletDriver } from '../../tools/e2e-tools.js'; +import { makeQueryClient } from '../../tools/query.js'; +import { makeRandomDigits } from '../../tools/random.js'; +import { createWallet } from '../../tools/wallet.js'; +import { commonSetup, type SetupContextWithWallets } from '../support.js'; +import { makeFeedPolicyPartial, oracleMnemonics } from './config.js'; const log = makeTracer('MCFU'); @@ -33,6 +33,7 @@ const makeRandomNumber = () => Math.random(); const test = anyTest as TestFn< SetupContextWithWallets & { lpUser: WalletDriver; + feeUser: WalletDriver; oracleWds: WalletDriver[]; nobleAgoricChannelId: IBCChannelID; usdcOnOsmosis: Denom; @@ -41,7 +42,7 @@ const test = anyTest as TestFn< } >; -const accounts = [...keys(oracleMnemonics), 'lp']; +const accounts = [...keys(oracleMnemonics), 'lp', 'feeDest']; const contractName = 'fastUsdc'; const contractBuilder = '../packages/builders/scripts/fast-usdc/start-fast-usdc.build.js'; @@ -95,10 +96,12 @@ test.before(async t => { USDC: 8_000n, BLD: 100n, }); + const feeUser = await provisionSmartWallet(wallets['feeDest'], { BLD: 100n }); t.context = { ...common, lpUser, + feeUser, oracleWds, nobleAgoricChannelId, usdcOnOsmosis, @@ -401,6 +404,33 @@ const advanceAndSettleScenario = test.macro({ }, }); +test('distribute FastUSDC contract fees', async t => { + const io = t.context; + const queryClient = makeQueryClient( + await io.useChain('agoric').getRestEndpoint(), + ); + const builder = + '../packages/builders/scripts/fast-usdc/fast-usdc-fees.build.js'; + + const opts = { + destinationAddress: io.wallets['feeDest'], + feePortion: 0.25, + }; + t.log('build, run proposal to distribute fees', opts); + await io.deployBuilder(builder, { + ...opts, + feePortion: `${opts.feePortion}`, + }); + + const { balance } = await io.retryUntilCondition( + () => queryClient.queryBalance(opts.destinationAddress, io.usdcDenom), + ({ balance }) => !!balance && BigInt(balance.amount) > 0n, + `fees received at ${opts.destinationAddress}`, + ); + t.log('fees received', balance); + t.truthy(balance?.amount); +}); + test.serial(advanceAndSettleScenario, LP_DEPOSIT_AMOUNT / 4n, 'osmosis'); test.serial(advanceAndSettleScenario, LP_DEPOSIT_AMOUNT / 8n, 'noble'); test.serial(advanceAndSettleScenario, LP_DEPOSIT_AMOUNT / 5n, 'agoric'); diff --git a/multichain-testing/tools/e2e-tools.js b/multichain-testing/tools/e2e-tools.js index 448f8b81895..f99d43e0aa4 100644 --- a/multichain-testing/tools/e2e-tools.js +++ b/multichain-testing/tools/e2e-tools.js @@ -559,7 +559,7 @@ export const makeE2ETools = async ( runCoreEval: buildAndRunCoreEval, /** * @param {string} address - * @param {Record} amount + * @param {Record} amount - should include BLD to pay for provisioning */ provisionSmartWallet: (address, amount) => provisionSmartWallet(address, amount, { diff --git a/multichain-testing/tools/wallet.ts b/multichain-testing/tools/wallet.ts index 44e62ce684c..b7144d97dae 100644 --- a/multichain-testing/tools/wallet.ts +++ b/multichain-testing/tools/wallet.ts @@ -1,8 +1,8 @@ import { Bip39, Random } from '@cosmjs/crypto'; import { DirectSecp256k1HdWallet } from '@cosmjs/proto-signing'; -export function generateMnemonic(): string { - return Bip39.encode(Random.getBytes(16)).toString(); +export function generateMnemonic(getBytes = Random.getBytes): string { + return Bip39.encode(getBytes(16)).toString(); } export const createWallet = async ( diff --git a/packages/boot/package.json b/packages/boot/package.json index 1226c3b8065..facfda69714 100644 --- a/packages/boot/package.json +++ b/packages/boot/package.json @@ -9,12 +9,13 @@ "build": "exit 0", "clean": "rm -rf bundles/config.*", "test": "ava", - "test:xs": "SWINGSET_WORKER_TYPE=xs-worker ava test/bootstrapTests test/upgrading test/fast-usdc", + "test:xs": "SWINGSET_WORKER_TYPE=xs-worker ava test/bootstrapTests test/upgrading", "lint-fix": "yarn lint:eslint --fix", "lint": "run-s --continue-on-error lint:*", "lint:types": "tsc", "lint:eslint": "eslint ." }, + "$scripts-note": "fast-usdc skipped in test:xs pending #10847", "keywords": [], "author": "Agoric", "license": "Apache-2.0", diff --git a/packages/boot/test/fast-usdc/fast-usdc.test.ts b/packages/boot/test/fast-usdc/fast-usdc.test.ts index 308b020e32d..cdd599cb545 100644 --- a/packages/boot/test/fast-usdc/fast-usdc.test.ts +++ b/packages/boot/test/fast-usdc/fast-usdc.test.ts @@ -5,15 +5,11 @@ import { encodeAddressHook } from '@agoric/cosmic-proto/address-hooks.js'; import { configurations } from '@agoric/fast-usdc/src/utils/deploy-config.js'; import { MockCctpTxEvidences } from '@agoric/fast-usdc/test/fixtures.js'; import { documentStorageSchema } from '@agoric/governance/tools/storageDoc.js'; -import { - BridgeId, - deeplyFulfilledObject, - NonNullish, - objectMap, -} from '@agoric/internal'; +import { BridgeId, NonNullish } from '@agoric/internal'; import { unmarshalFromVstorage } from '@agoric/internal/src/marshal.js'; import { defaultSerializer } from '@agoric/internal/src/storage-test-utils.js'; import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { buildVTransferEvent } from '@agoric/orchestration/tools/ibc-mocks.js'; import { Fail } from '@endo/errors'; import { makeMarshal } from '@endo/marshal'; import { @@ -175,16 +171,6 @@ test.serial('writes fee config to vstorage', async t => { await documentStorageSchema(t, storage, doc); }); -test.serial('writes pool metrics to vstorage', async t => { - const { storage } = t.context; - const doc = { - node: 'fastUsdc.poolMetrics', - owner: 'FastUSC LiquidityPool exo', - showValue: defaultSerializer.parse, - }; - await documentStorageSchema(t, storage, doc); -}); - test.serial('writes account addresses to vstorage', async t => { const { storage } = t.context; const doc = { @@ -298,7 +284,9 @@ test.serial('makes usdc advance', async t => { const EUD = 'dydx1anything'; const lastNodeValue = storage.getValues('published.fastUsdc').at(-1); - const { settlementAccount } = JSON.parse(NonNullish(lastNodeValue)); + const { settlementAccount, poolAccount } = JSON.parse( + NonNullish(lastNodeValue), + ); const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO( // mock with the real settlementAccount address encodeAddressHook(settlementAccount, { EUD }), @@ -327,16 +315,53 @@ test.serial('makes usdc advance', async t => { ); harness?.resetRunPolicy(); - t.deepEqual( + const getTxStatus = txHash => storage - .getValues(`published.fastUsdc.txns.${evidence.txHash}`) - .map(defaultSerializer.parse), - [ - { evidence, status: 'OBSERVED' }, // observation includes evidence observed - { status: 'ADVANCING' }, - ], + .getValues(`published.fastUsdc.txns.${txHash}`) + .map(defaultSerializer.parse); + + t.deepEqual(getTxStatus(evidence.txHash), [ + { evidence, status: 'OBSERVED' }, // observation includes evidence observed + { status: 'ADVANCING' }, + ]); + + const { runInbound } = t.context.bridgeUtils; + await runInbound( + BridgeId.VTRANSFER, + buildVTransferEvent({ + sender: poolAccount, + target: poolAccount, + sourceChannel: 'channel-62', + sequence: '1', + }), ); + // in due course, minted USDC arrives + await runInbound( + BridgeId.VTRANSFER, + + buildVTransferEvent({ + sequence: '1', // arbitrary; not used + amount: evidence.tx.amount, + denom: 'uusdc', + sender: evidence.tx.forwardingAddress, + target: settlementAccount, + receiver: encodeAddressHook(settlementAccount, { EUD }), + sourceChannel: evidence.aux.forwardingChannel, + destinationChannel: 'channel-62', // fetchedChainInfo + // destinationChannel: evidence.aux.forwardingChannel, + // sourceChannel: 'channel-62', // fetchedChainInfo + }), + ); + + await eventLoopIteration(); + t.like(getTxStatus(evidence.txHash), [ + { status: 'OBSERVED' }, + { status: 'ADVANCING' }, + { status: 'ADVANCED' }, + { status: 'DISBURSED', split: { ContractFee: { value: 302000n } } }, + ]); + const doc = { node: `fastUsdc.txns`, owner: `the Ethereum transactions upon which Fast USDC is acting`, @@ -345,6 +370,42 @@ test.serial('makes usdc advance', async t => { await documentStorageSchema(t, storage, doc); }); +test.serial('writes pool metrics to vstorage', async t => { + const { storage } = t.context; + const doc = { + node: 'fastUsdc.poolMetrics', + owner: 'FastUSC LiquidityPool exo', + showValue: defaultSerializer.parse, + }; + await documentStorageSchema(t, storage, doc); +}); + +test.serial('distributes fees per BLD staker decision', async t => { + const { walletFactoryDriver: wd, buildProposal, evalProposal } = t.context; + + const ContractFee = 302000n; // see split above + t.is(((ContractFee - 250000n) * 5n) / 10n, 26000n); + const cases = [ + { dest: 'agoric1a', args: ['--fixedFees', '0.25'], rxd: '250000' }, + { dest: 'agoric1b', args: ['--feePortion', '0.5'], rxd: '26000' }, + ]; + for (const { dest, args, rxd } of cases) { + await wd.provideSmartWallet(dest); + const materials = buildProposal( + '@agoric/builders/scripts/fast-usdc/fast-usdc-fees.build.js', + ['--destinationAddress', dest, ...args], + ); + await evalProposal(materials); + + const { getOutboundMessages } = t.context.bridgeUtils; + const found = getOutboundMessages(BridgeId.BANK).find( + msg => msg.recipient === dest && msg.type === 'VBANK_GIVE', + ); + t.log('dest vbank msg', found); + t.like(found, { amount: rxd }); + } +}); + test.serial('skips usdc advance when risks identified', async t => { const { walletFactoryDriver: wfd, storage } = t.context; const oracles = await Promise.all([ diff --git a/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.md b/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.md index 68ea14d4de8..b223719d6ec 100644 --- a/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.md +++ b/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.md @@ -132,51 +132,6 @@ Generated by [AVA](https://avajs.dev). ], ] -## writes pool metrics to vstorage - -> Under "published", the "fastUsdc.poolMetrics" node is delegated to FastUSC LiquidityPool exo. -> The example below illustrates the schema of the data published there. -> -> See also board marshalling conventions (_to appear_). - - [ - [ - 'published.fastUsdc.poolMetrics', - { - encumberedBalance: { - brand: Object @Alleged: USDC brand {}, - value: 0n, - }, - shareWorth: { - denominator: { - brand: Object @Alleged: PoolShares brand {}, - value: 1n, - }, - numerator: { - brand: Object @Alleged: USDC brand {}, - value: 1n, - }, - }, - totalBorrows: { - brand: Object @Alleged: USDC brand {}, - value: 0n, - }, - totalContractFees: { - brand: Object @Alleged: USDC brand {}, - value: 0n, - }, - totalPoolFees: { - brand: Object @Alleged: USDC brand {}, - value: 0n, - }, - totalRepays: { - brand: Object @Alleged: USDC brand {}, - value: 0n, - }, - }, - ], - ] - ## writes account addresses to vstorage > Under "published", the "fastUsdc" node is delegated to FastUSDC contract. @@ -230,7 +185,66 @@ Generated by [AVA](https://avajs.dev). [ 'published.fastUsdc.txns.0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702', { - status: 'ADVANCING', + split: { + ContractFee: { + brand: Object @Alleged: USDC brand {}, + value: 302000n, + }, + PoolFee: { + brand: Object @Alleged: USDC brand {}, + value: 1208000n, + }, + Principal: { + brand: Object @Alleged: USDC brand {}, + value: 148490000n, + }, + }, + status: 'DISBURSED', + }, + ], + ] + +## writes pool metrics to vstorage + +> Under "published", the "fastUsdc.poolMetrics" node is delegated to FastUSC LiquidityPool exo. +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.fastUsdc.poolMetrics', + { + encumberedBalance: { + brand: Object @Alleged: USDC brand {}, + value: 0n, + }, + shareWorth: { + denominator: { + brand: Object @Alleged: PoolShares brand {}, + value: 150000001n, + }, + numerator: { + brand: Object @Alleged: USDC brand {}, + value: 151208001n, + }, + }, + totalBorrows: { + brand: Object @Alleged: USDC brand {}, + value: 148490000n, + }, + totalContractFees: { + brand: Object @Alleged: USDC brand {}, + value: 302000n, + }, + totalPoolFees: { + brand: Object @Alleged: USDC brand {}, + value: 1208000n, + }, + totalRepays: { + brand: Object @Alleged: USDC brand {}, + value: 148490000n, + }, }, ], ] @@ -246,7 +260,21 @@ Generated by [AVA](https://avajs.dev). [ 'published.fastUsdc.txns.0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702', { - status: 'ADVANCING', + split: { + ContractFee: { + brand: Object @Alleged: USDC brand {}, + value: 302000n, + }, + PoolFee: { + brand: Object @Alleged: USDC brand {}, + value: 1208000n, + }, + Principal: { + brand: Object @Alleged: USDC brand {}, + value: 148490000n, + }, + }, + status: 'DISBURSED', }, ], [ diff --git a/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.snap b/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.snap index 852bea26923..48697d3e3c2 100644 Binary files a/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.snap and b/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.snap differ diff --git a/packages/builders/scripts/fast-usdc/fast-usdc-fees.build.js b/packages/builders/scripts/fast-usdc/fast-usdc-fees.build.js new file mode 100644 index 00000000000..f9587473a80 --- /dev/null +++ b/packages/builders/scripts/fast-usdc/fast-usdc-fees.build.js @@ -0,0 +1,75 @@ +import { makeHelpers } from '@agoric/deploy-script-support'; +import { AmountMath } from '@agoric/ertp'; +import { getManifestForDistributeFees } from '@agoric/fast-usdc/src/distribute-fees.core.js'; +import { toExternalConfig } from '@agoric/fast-usdc/src/utils/config-marshal.js'; +import { + multiplyBy, + parseRatio, +} from '@agoric/zoe/src/contractSupport/ratio.js'; +import { Far } from '@endo/far'; +import { parseArgs } from 'node:util'; + +/** + * @import {CoreEvalBuilder, DeployScriptFunction} from '@agoric/deploy-script-support/src/externalTypes.js' + * @import {FeeDistributionTerms} from '@agoric/fast-usdc/src/distribute-fees.core.js' + */ + +const usage = + 'Use: [--fixedFees | --feePortion ] --destinationAddress
...'; + +const xVatCtx = /** @type {const} */ ({ + /** @type {Brand<'nat'>} */ + USDC: Far('USDC Brand'), +}); +const { USDC } = xVatCtx; +const USDC_DECIMALS = 6n; +const unit = AmountMath.make(USDC, 10n ** USDC_DECIMALS); + +/** + * @param {unknown} _utils + * @param {FeeDistributionTerms} feeTerms + * @satisfies {CoreEvalBuilder} + */ +export const feeProposalBuilder = async (_utils, feeTerms) => { + return harden({ + sourceSpec: '@agoric/fast-usdc/src/distribute-fees.core.js', + /** @type {[string, Parameters[1]]} */ + getManifestCall: [ + getManifestForDistributeFees.name, + { options: toExternalConfig(harden({ feeTerms }), xVatCtx) }, + ], + }); +}; + +/** @type {DeployScriptFunction} */ +export default async (homeP, endowments) => { + const { writeCoreEval } = await makeHelpers(homeP, endowments); + /** @type {{ values: Record }} */ + const { + values: { destinationAddress, ...opt }, + } = parseArgs({ + args: endowments.scriptArgs, + options: { + destinationAddress: { type: 'string' }, + fixedFees: { type: 'string' }, + feePortion: { type: 'string' }, + }, + }); + if (!destinationAddress) assert.fail(usage); + if (opt.fixedFees && opt.feePortion) assert.fail(usage); + + /** @type {FeeDistributionTerms} */ + const feeTerms = { + destinationAddress, + ...((opt.fixedFees && { + fixedFees: multiplyBy(unit, parseRatio(opt.fixedFees, USDC)), + }) || + (opt.feePortion && { + feePortion: parseRatio(opt.feePortion, USDC), + }) || + assert.fail(usage)), + }; + await writeCoreEval('eval-fast-usdc-fees', utils => + feeProposalBuilder(utils, feeTerms), + ); +}; diff --git a/packages/fast-usdc/src/distribute-fees.core.js b/packages/fast-usdc/src/distribute-fees.core.js new file mode 100644 index 00000000000..edd1d9ab9e0 --- /dev/null +++ b/packages/fast-usdc/src/distribute-fees.core.js @@ -0,0 +1,93 @@ +/** @file core eval module to collect fees. */ +import { AmountMath } from '@agoric/ertp'; +import { floorMultiplyBy } from '@agoric/zoe/src/contractSupport/index.js'; +import { E } from '@endo/far'; +import { makeTracer } from '@agoric/internal'; +import { fromExternalConfig } from './utils/config-marshal.js'; + +/** + * @import {DepositFacet} from '@agoric/ertp'; + * @import {FastUSDCCorePowers} from '@agoric/fast-usdc/src/start-fast-usdc.core.js'; + * @import {CopyRecord} from '@endo/pass-style' + * @import {BootstrapManifestPermit} from '@agoric/vats/src/core/lib-boot.js' + * @import {LegibleCapData} from './utils/config-marshal.js' + */ + +/** + * @typedef {{ destinationAddress: string } & + * ({ feePortion: Ratio} | {fixedFees: Amount<'nat'>}) & + * CopyRecord + * } FeeDistributionTerms + */ + +const kwUSDC = 'USDC'; // keyword in AmountKeywordRecord +const issUSDC = 'USDC'; // issuer name + +const trace = makeTracer('FUCF', true); + +/** + * @param {BootstrapPowers & FastUSDCCorePowers } permittedPowers + * @param {{ options: LegibleCapData<{ feeTerms: FeeDistributionTerms}> }} config + */ +export const distributeFees = async (permittedPowers, config) => { + trace('distributeFees...', config.options); + + const { agoricNames, namesByAddress, zoe } = permittedPowers.consume; + /** @type {Brand<'nat'>} */ + const usdcBrand = await E(agoricNames).lookup('brand', issUSDC); + /** @type {{ feeTerms: FeeDistributionTerms}} */ + const { feeTerms: terms } = fromExternalConfig(config.options, { + USDC: usdcBrand, + }); + + const { creatorFacet } = await permittedPowers.consume.fastUsdcKit; + const want = { + [kwUSDC]: await ('fixedFees' in terms + ? terms.fixedFees + : E(creatorFacet) + .getContractFeeBalance() + .then(balance => floorMultiplyBy(balance, terms.feePortion))), + }; + const proposal = harden({ want }); + + /** @type {DepositFacet} */ + const depositFacet = await E(namesByAddress).lookup( + terms.destinationAddress, + 'depositFacet', + ); + trace('to:', terms.destinationAddress, depositFacet); + + const toWithdraw = await E(creatorFacet).makeWithdrawFeesInvitation(); + trace('invitation:', toWithdraw, 'proposal:', proposal); + const seat = E(zoe).offer(toWithdraw, proposal); + const result = await E(seat).getOfferResult(); + trace('offer result', result); + const payout = await E(seat).getPayout(kwUSDC); + /** @type {Amount<'nat'>} */ + // @ts-expect-error USDC is a nat brand + const rxd = await E(depositFacet).receive(payout); + trace('received', rxd); + if (!AmountMath.isGTE(rxd, proposal.want[kwUSDC])) { + trace('🚨 expected', proposal.want[kwUSDC], 'got', rxd); + } + trace('done'); +}; +harden(distributeFees); + +/** @satisfies {BootstrapManifestPermit} */ +const permit = { + consume: { + fastUsdcKit: true, + agoricNames: true, + namesByAddress: true, + zoe: true, + }, +}; + +/** + * @param {unknown} _utils + * @param {Parameters[1]} config + */ +export const getManifestForDistributeFees = (_utils, { options }) => { + return { manifest: { [distributeFees.name]: permit }, options }; +}; diff --git a/packages/fast-usdc/src/exos/liquidity-pool.js b/packages/fast-usdc/src/exos/liquidity-pool.js index 9116fb08062..34b16ce807c 100644 --- a/packages/fast-usdc/src/exos/liquidity-pool.js +++ b/packages/fast-usdc/src/exos/liquidity-pool.js @@ -1,4 +1,4 @@ -import { AmountMath } from '@agoric/ertp'; +import { AmountMath, AmountShape } from '@agoric/ertp'; import { makeRecorderTopic, TopicsRecordShape, @@ -107,11 +107,18 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => { withdrawHandler: M.interface('withdrawHandler', { handle: M.call(SeatShape, M.any()).returns(M.promise()), }), + withdrawFeesHandler: M.interface('withdrawFeesHandler', { + handle: M.call(SeatShape, M.any()).returns(M.promise()), + }), public: M.interface('public', { makeDepositInvitation: M.call().returns(M.promise()), makeWithdrawInvitation: M.call().returns(M.promise()), getPublicTopics: M.call().returns(TopicsRecordShape), }), + feeRecipient: M.interface('feeRecipient', { + getContractFeeBalance: M.call().returns(AmountShape), + makeWithdrawFeesInvitation: M.call().returns(M.promise()), + }), }, /** * @param {ZCFMint<'nat'>} shareMint @@ -326,6 +333,21 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => { external.publishPoolMetrics(); }, }, + withdrawFeesHandler: { + /** @param {ZCFSeat} seat */ + async handle(seat) { + const { feeSeat } = this.state; + + const { want } = seat.getProposal(); + const available = feeSeat.getAmountAllocated('USDC', want.USDC.brand); + isGTE(available, want.USDC) || + Fail`cannot withdraw ${want.USDC}; only ${available} available`; + + // COMMIT POINT + zcf.atomicRearrange(harden([[feeSeat, seat, want]])); + seat.exit(); + }, + }, public: { makeDepositInvitation() { return zcf.makeInvitation( @@ -353,6 +375,22 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => { }; }, }, + feeRecipient: { + getContractFeeBalance() { + const { feeSeat } = this.state; + /** @type {Amount<'nat'>} */ + const balance = feeSeat.getCurrentAllocation().USDC; + return balance; + }, + makeWithdrawFeesInvitation() { + return zcf.makeInvitation( + this.facets.withdrawFeesHandler, + 'Withdraw Fees', + undefined, + this.state.proposalShapes.withdrawFees, + ); + }, + }, }, { finish: ({ facets: { external } }) => { diff --git a/packages/fast-usdc/src/fast-usdc.contract.js b/packages/fast-usdc/src/fast-usdc.contract.js index 8970b8a8117..cfd060f86f3 100644 --- a/packages/fast-usdc/src/fast-usdc.contract.js +++ b/packages/fast-usdc/src/fast-usdc.contract.js @@ -161,6 +161,13 @@ export const contract = async (zcf, privateArgs, zone, tools) => { removeOperator(operatorId) { return feedKit.creator.removeOperator(operatorId); }, + async getContractFeeBalance() { + return poolKit.feeRecipient.getContractFeeBalance(); + }, + /** @type {() => Promise>} */ + async makeWithdrawFeesInvitation() { + return poolKit.feeRecipient.makeWithdrawFeesInvitation(); + }, async connectToNoble() { return vowTools.when(nobleAccountV, nobleAccount => { trace('nobleAccount', nobleAccount); diff --git a/packages/fast-usdc/src/pool-share-math.js b/packages/fast-usdc/src/pool-share-math.js index d208c8aadf6..1d9994a985d 100644 --- a/packages/fast-usdc/src/pool-share-math.js +++ b/packages/fast-usdc/src/pool-share-math.js @@ -44,6 +44,9 @@ export const makeParity = (numerator, denominatorBrand) => { * withdraw: { * give: { PoolShare: Amount<'nat'> } * want: { USDC: Amount<'nat'> }, + * }, + * withdrawFees: { + * want: { USDC: Amount<'nat'> } * } * }} USDCProposalShapes */ diff --git a/packages/fast-usdc/src/start-fast-usdc.core.js b/packages/fast-usdc/src/start-fast-usdc.core.js index 1474c17fdc6..6f7efc8f590 100644 --- a/packages/fast-usdc/src/start-fast-usdc.core.js +++ b/packages/fast-usdc/src/start-fast-usdc.core.js @@ -1,22 +1,22 @@ import { deeplyFulfilledObject, makeTracer } from '@agoric/internal'; -import { Fail } from '@endo/errors'; import { E } from '@endo/far'; -import { makeMarshal } from '@endo/marshal'; import { FastUSDCConfigShape } from './type-guards.js'; import { fromExternalConfig } from './utils/config-marshal.js'; -import { inviteOracles, publishFeedPolicy } from './utils/core-eval.js'; +import { + inviteOracles, + publishDisplayInfo, + publishFeedPolicy, +} from './utils/core-eval.js'; /** - * @import {Amount, Brand, DepositFacet, Issuer, Payment} from '@agoric/ertp'; - * @import {TypedPattern} from '@agoric/internal' + * @import {Brand, Issuer} from '@agoric/ertp'; * @import {Instance, StartParams} from '@agoric/zoe/src/zoeService/utils' * @import {Board} from '@agoric/vats' * @import {ManifestBundleRef} from '@agoric/deploy-script-support/src/externalTypes.js' * @import {BootstrapManifest} from '@agoric/vats/src/core/lib-boot.js' - * @import {Passable} from '@endo/pass-style' * @import {LegibleCapData} from './utils/config-marshal.js' * @import {FastUsdcSF} from './fast-usdc.contract.js' - * @import {FeedPolicy, FastUSDCConfig} from './types.js' + * @import {FastUSDCConfig} from './types.js' */ const ShareAssetInfo = /** @type {const} */ harden({ @@ -46,26 +46,6 @@ const makePublishingStorageKit = async (path, { chainStorage, board }) => { return { storageNode, marshaller }; }; -const BOARD_AUX = 'boardAux'; -const marshalData = makeMarshal(_val => Fail`data only`); -/** - * @param {Brand} brand - * @param {Pick} powers - */ -const publishDisplayInfo = async (brand, { board, chainStorage }) => { - // chainStorage type includes undefined, which doesn't apply here. - // @ts-expect-error UNTIL https://github.com/Agoric/agoric-sdk/issues/8247 - const boardAux = E(chainStorage).makeChildNode(BOARD_AUX); - const [id, displayInfo, allegedName] = await Promise.all([ - E(board).getId(brand), - E(brand).getDisplayInfo(), - E(brand).getAllegedName(), - ]); - const node = E(boardAux).makeChildNode(id); - const aux = marshalData.toCapData(harden({ allegedName, displayInfo })); - await E(node).setValue(JSON.stringify(aux)); -}; - const POOL_METRICS = 'poolMetrics'; /** @@ -79,7 +59,7 @@ const POOL_METRICS = 'poolMetrics'; * }} FastUSDCCorePowers * * @typedef {StartedInstanceKitWithLabel & { - * creatorFacet: Awaited>['creatorFacet']; + * creatorFacet: StartedInstanceKit['creatorFacet']; * privateArgs: StartParams['privateArgs']; * }} FastUSDCKit */ @@ -245,10 +225,10 @@ export const getManifestForFastUSDC = ( board: true, }, issuer: { - produce: { FastLP: true }, // UNTIL #10432 + produce: { FastLP: true }, }, brand: { - produce: { FastLP: true }, // UNTIL #10432 + produce: { FastLP: true }, }, instance: { produce: { fastUsdc: true }, diff --git a/packages/fast-usdc/src/type-guards.js b/packages/fast-usdc/src/type-guards.js index b365f53dbb7..82f801044c2 100644 --- a/packages/fast-usdc/src/type-guards.js +++ b/packages/fast-usdc/src/type-guards.js @@ -34,7 +34,11 @@ export const makeProposalShapes = ({ PoolShares, USDC }) => { give: { PoolShare: makeNatAmountShape(PoolShares, 1n) }, want: { USDC: makeNatAmountShape(USDC, 1n) }, }); - return harden({ deposit, withdraw }); + /** @type {TypedPattern} */ + const withdrawFees = M.splitRecord({ + want: { USDC: makeNatAmountShape(USDC, 1n) }, + }); + return harden({ deposit, withdraw, withdrawFees }); }; /** @type {TypedPattern} */ diff --git a/packages/fast-usdc/src/utils/core-eval.js b/packages/fast-usdc/src/utils/core-eval.js index 70784d97eb2..d5ca7d72abc 100644 --- a/packages/fast-usdc/src/utils/core-eval.js +++ b/packages/fast-usdc/src/utils/core-eval.js @@ -6,9 +6,7 @@ import { makeMarshal } from '@endo/marshal'; const trace = makeTracer('FUCoreEval'); /** - * @import {Amount, Brand, DepositFacet, Issuer, Payment} from '@agoric/ertp'; - * @import {Passable} from '@endo/pass-style' - * @import {BootstrapManifest} from '@agoric/vats/src/core/lib-boot.js' + * @import {Brand, DepositFacet} from '@agoric/ertp'; * @import {FastUSDCKit} from '../start-fast-usdc.core.js' * @import {FeedPolicy} from '../types.js' */ @@ -54,3 +52,22 @@ export const inviteOracles = async ( }), ); }; + +const BOARD_AUX = 'boardAux'; +/** + * @param {Brand} brand + * @param {Pick} powers + */ +export const publishDisplayInfo = async (brand, { board, chainStorage }) => { + // chainStorage type includes undefined, which doesn't apply here. + // @ts-expect-error UNTIL https://github.com/Agoric/agoric-sdk/issues/8247 + const boardAux = E(chainStorage).makeChildNode(BOARD_AUX); + const [id, displayInfo, allegedName] = await Promise.all([ + E(board).getId(brand), + E(brand).getDisplayInfo(), + E(brand).getAllegedName(), + ]); + const node = E(boardAux).makeChildNode(id); + const aux = marshalData.toCapData(harden({ allegedName, displayInfo })); + await E(node).setValue(JSON.stringify(aux)); +}; diff --git a/packages/fast-usdc/test/fast-usdc.contract.test.ts b/packages/fast-usdc/test/fast-usdc.contract.test.ts index ebd1bd5caea..1ad44bac49d 100644 --- a/packages/fast-usdc/test/fast-usdc.contract.test.ts +++ b/packages/fast-usdc/test/fast-usdc.contract.test.ts @@ -5,6 +5,7 @@ import { decodeAddressHook, encodeAddressHook, } from '@agoric/cosmic-proto/address-hooks.js'; +import type { Amount, Issuer, NatValue, Purse } from '@agoric/ertp'; import { AmountMath } from '@agoric/ertp/src/amountMath.js'; import { eventLoopIteration, @@ -31,15 +32,14 @@ import { E } from '@endo/far'; import { matches } from '@endo/patterns'; import { makePromiseKit } from '@endo/promise-kit'; import path from 'path'; -import type { Amount, Issuer, NatValue, Purse } from '@agoric/ertp'; -import type { OperatorKit } from '../src/exos/operator-kit.js'; +import type { OperatorOfferResult } from '../src/exos/transaction-feed.js'; import type { FastUsdcSF } from '../src/fast-usdc.contract.js'; -import { CctpTxEvidenceShape, PoolMetricsShape } from '../src/type-guards.js'; +import type { USDCProposalShapes } from '../src/pool-share-math.js'; +import { PoolMetricsShape } from '../src/type-guards.js'; import type { CctpTxEvidence, FeeConfig, PoolMetrics } from '../src/types.js'; import { makeFeeTools } from '../src/utils/fees.js'; import { MockCctpTxEvidences } from './fixtures.js'; import { commonSetup, uusdcOnAgoric } from './supports.js'; -import type { OperatorOfferResult } from '../src/exos/transaction-feed.js'; const dirname = path.dirname(new URL(import.meta.url).pathname); @@ -780,6 +780,46 @@ test.serial('STORY05(cont): LPs withdraw all liquidity', async t => { t.truthy(b); }); +test.serial('withdraw fees using creatorFacet', async t => { + const { + startKit: { zoe, creatorFacet }, + common: { + brands: { usdc }, + }, + } = t.context; + const proposal: USDCProposalShapes['withdrawFees'] = { + want: { USDC: usdc.units(1.25) }, + }; + + const usdPurse = await E(usdc.issuer).makeEmptyPurse(); + { + const balancePre = await E(creatorFacet).getContractFeeBalance(); + t.log('contract fee balance before withdrawal', balancePre); + const toWithdraw = await E(creatorFacet).makeWithdrawFeesInvitation(); + const seat = E(zoe).offer(toWithdraw, proposal); + await t.notThrowsAsync(E(seat).getOfferResult()); + const payout = await E(seat).getPayout('USDC'); + const amt = await E(usdPurse).deposit(payout); + t.log('withdrew fees', amt); + t.deepEqual(amt, usdc.units(1.25)); + const balancePost = await E(creatorFacet).getContractFeeBalance(); + t.log('contract fee balance after withdrawal', balancePost); + t.deepEqual(AmountMath.subtract(balancePre, usdc.units(1.25)), balancePost); + } + + { + const toWithdraw = await E(creatorFacet).makeWithdrawFeesInvitation(); + const tooMuch = { USDC: usdc.units(20) }; + const seat = E(zoe).offer(toWithdraw, { want: tooMuch }); + await t.throwsAsync(E(seat).getOfferResult(), { + message: /cannot withdraw {.*}; only {.*} available/, + }); + const payout = await E(seat).getPayout('USDC'); + const amt = await E(usdPurse).deposit(payout); + t.deepEqual(amt, usdc.units(0)); + } +}); + test.serial('STORY09: insufficient liquidity: no FastUSDC option', async t => { // STORY09 - As the Fast USDC end user, // I should see the option to use Fast USDC unavailable diff --git a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md index abab8df6efd..2c66c2e5ca9 100644 --- a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md +++ b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md @@ -587,8 +587,10 @@ Generated by [AVA](https://avajs.dev). borrower: Object @Alleged: Liquidity Pool borrower {}, depositHandler: Object @Alleged: Liquidity Pool depositHandler {}, external: Object @Alleged: Liquidity Pool external {}, + feeRecipient: Object @Alleged: Liquidity Pool feeRecipient {}, public: Object @Alleged: Liquidity Pool public {}, repayer: Object @Alleged: Liquidity Pool repayer {}, + withdrawFeesHandler: Object @Alleged: Liquidity Pool withdrawFeesHandler {}, withdrawHandler: Object @Alleged: Liquidity Pool withdrawHandler {}, }, 'Liquidity Pool_kindHandle': 'Alleged: kind', diff --git a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap index 07715403500..0f69db9c66b 100644 Binary files a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap and b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap differ