diff --git a/packages/fast-usdc/src/exos/advancer.js b/packages/fast-usdc/src/exos/advancer.js index e772f2ecb4a..9c5ffa536fe 100644 --- a/packages/fast-usdc/src/exos/advancer.js +++ b/packages/fast-usdc/src/exos/advancer.js @@ -1,4 +1,4 @@ -import { AmountMath, AmountShape, PaymentShape } from '@agoric/ertp'; +import { AmountMath, AmountShape } from '@agoric/ertp'; import { assertAllDefined } from '@agoric/internal'; import { ChainAddressShape } from '@agoric/orchestration'; import { pickFacet } from '@agoric/vat-data'; @@ -15,31 +15,25 @@ const { isGTE } = AmountMath; /** * @import {HostInterface} from '@agoric/async-flow'; * @import {NatAmount} from '@agoric/ertp'; - * @import {ChainAddress, ChainHub, Denom, DenomAmount, OrchestrationAccount} from '@agoric/orchestration'; + * @import {ChainAddress, ChainHub, Denom, OrchestrationAccount} from '@agoric/orchestration'; + * @import {ZoeTools} from '@agoric/orchestration/src/utils/zoe-tools.js'; * @import {VowTools} from '@agoric/vow'; * @import {Zone} from '@agoric/zone'; * @import {CctpTxEvidence, FeeConfig, LogFn} from '../types.js'; * @import {StatusManager} from './status-manager.js'; - */ - -/** - * Expected interface from LiquidityPool - * - * @typedef {{ - * lookupBalance(): NatAmount; - * borrow(amount: Amount<"nat">): Promise>; - * repay(payments: PaymentKeywordRecord): Promise - * }} AssetManagerFacet + * @import {LiquidityPoolKit} from './liquidity-pool.js'; */ /** * @typedef {{ * chainHub: ChainHub; * feeConfig: FeeConfig; + * localTransfer: ZoeTools['localTransfer']; * log: LogFn; * statusManager: StatusManager; * usdc: { brand: Brand<'nat'>; denom: Denom; }; * vowTools: VowTools; + * zcf: ZCF; * }} AdvancerKitPowers */ @@ -49,13 +43,15 @@ const AdvancerKitI = harden({ handleTransactionEvent: M.callWhen(CctpTxEvidenceShape).returns(), }), depositHandler: M.interface('DepositHandlerI', { - onFulfilled: M.call(AmountShape, { + onFulfilled: M.call(M.undefined(), { + amount: AmountShape, destination: ChainAddressShape, - payment: PaymentShape, + tmpSeat: M.remotable(), }).returns(VowShape), onRejected: M.call(M.error(), { + amount: AmountShape, destination: ChainAddressShape, - payment: PaymentShape, + tmpSeat: M.remotable(), }).returns(), }), transferHandler: M.interface('TransferHandlerI', { @@ -77,7 +73,16 @@ const AdvancerKitI = harden({ */ export const prepareAdvancerKit = ( zone, - { chainHub, feeConfig, log, statusManager, usdc, vowTools: { watch, when } }, + { + chainHub, + feeConfig, + localTransfer, + log, + statusManager, + usdc, + vowTools: { watch, when }, + zcf, + }, ) => { assertAllDefined({ chainHub, @@ -95,7 +100,7 @@ export const prepareAdvancerKit = ( AdvancerKitI, /** * @param {{ - * assetManagerFacet: AssetManagerFacet; + * borrowerFacet: LiquidityPoolKit['borrower']; * poolAccount: ERef>>; * }} config */ @@ -115,8 +120,7 @@ export const prepareAdvancerKit = ( async handleTransactionEvent(evidence) { await null; try { - // TODO poolAccount might be a vow we need to unwrap - const { assetManagerFacet, poolAccount } = this.state; + const { borrowerFacet, poolAccount } = this.state; const { recipientAddress } = evidence.aux; const { EUD } = addressTools.getQueryParams( recipientAddress, @@ -129,14 +133,12 @@ export const prepareAdvancerKit = ( const advanceAmount = feeTools.calculateAdvance(requestedAmount); // TODO: consider skipping and using `borrow()`s internal balance check - const poolBalance = assetManagerFacet.lookupBalance(); + const poolBalance = borrowerFacet.getBalance(); if (!isGTE(poolBalance, requestedAmount)) { log( `Insufficient pool funds`, `Requested ${q(advanceAmount)} but only have ${q(poolBalance)}`, ); - // report `requestedAmount`, not `advancedAmount`... do we need to - // communicate net to `StatusManger` in case fees change in between? statusManager.observe(evidence); return; } @@ -152,19 +154,29 @@ export const prepareAdvancerKit = ( return; } + const { zcfSeat: tmpSeat } = zcf.makeEmptySeatKit(); + const amountKWR = harden({ USDC: advanceAmount }); try { - const payment = await assetManagerFacet.borrow(advanceAmount); - const depositV = E(poolAccount).deposit(payment); - void watch(depositV, this.facets.depositHandler, { - destination, - payment, - }); + borrowerFacet.borrow(tmpSeat, amountKWR); } catch (e) { - // `.borrow()` might fail if the balance changes since we - // requested it. TODO - how to handle this? change ADVANCED -> OBSERVED? - // Note: `depositHandler` handles the `.deposit()` failure + // We do not expect this to fail since there are no turn boundaries + // between .getBalance() and .borrow(). + // We catch to report outside of the normal error flow since this is + // not expected. log('🚨 advance borrow failed', q(e).toString()); } + + const depositV = localTransfer( + tmpSeat, + // @ts-expect-error LocalAccountMethods vs OrchestrationAccount + poolAccount, + amountKWR, + ); + void watch(depositV, this.facets.depositHandler, { + amount: advanceAmount, + destination, + tmpSeat, + }); } catch (e) { log('Advancer error:', q(e).toString()); statusManager.observe(evidence); @@ -173,18 +185,16 @@ export const prepareAdvancerKit = ( }, depositHandler: { /** - * @param {NatAmount} amount amount returned from deposit - * @param {{ destination: ChainAddress; payment: Payment<'nat'> }} ctx + * @param {undefined} result + * @param {{ amount: Amount<'nat'>; destination: ChainAddress; tmpSeat: ZCFSeat }} ctx */ - onFulfilled(amount, { destination }) { + onFulfilled(result, { amount, destination }) { + // TODO do we need to ensure this isn't Vow for an LOA? const { poolAccount } = this.state; - const transferV = E(poolAccount).transfer( - destination, - /** @type {DenomAmount} */ ({ - denom: usdc.denom, - value: amount.value, - }), - ); + const transferV = E(poolAccount).transfer(destination, { + denom: usdc.denom, + value: amount.value, + }); return watch(transferV, this.facets.transferHandler, { destination, amount, @@ -192,12 +202,17 @@ export const prepareAdvancerKit = ( }, /** * @param {Error} error - * @param {{ destination: ChainAddress; payment: Payment<'nat'> }} ctx + * @param {{ amount: Amount<'nat'>; destination: ChainAddress; tmpSeat: ZCFSeat }} ctx */ - onRejected(error, { payment }) { - // TODO return live payment from ctx to LP + onRejected(error, { tmpSeat }) { + // TODO return seat allocation from ctx to LP? log('🚨 advance deposit failed', q(error).toString()); - log('TODO live payment to return to LP', q(payment).toString()); + // TODO #10510 (comprehensive error testing) determine + // course of action here + log( + 'TODO live payment on seat to return to LP', + q(tmpSeat).toString(), + ); }, }, transferHandler: { @@ -206,22 +221,23 @@ export const prepareAdvancerKit = ( * @param {{ destination: ChainAddress; amount: NatAmount; }} ctx */ onFulfilled(result, { destination, amount }) { - // TODO vstorage update? + // TODO vstorage update? We don't currently have a status for + // Advanced + transferV settled log( 'Advance transfer fulfilled', q({ amount, destination, result }).toString(), ); }, onRejected(error) { - // XXX retry logic? - // What do we do if we fail, should we keep a Status? + // TODO #10510 (comprehensive error testing) determine + // course of action here. This might fail due to timeout. log('Advance transfer rejected', q(error).toString()); }, }, }, { stateShape: harden({ - assetManagerFacet: M.remotable(), + borrowerFacet: M.remotable(), poolAccount: M.or(VowShape, M.remotable()), }), }, diff --git a/packages/fast-usdc/src/fast-usdc.contract.js b/packages/fast-usdc/src/fast-usdc.contract.js index 80b6602e35c..179cbf82c2b 100644 --- a/packages/fast-usdc/src/fast-usdc.contract.js +++ b/packages/fast-usdc/src/fast-usdc.contract.js @@ -14,6 +14,7 @@ import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/record import { E } from '@endo/far'; import { M, objectMap } from '@endo/patterns'; import { depositToSeat } from '@agoric/zoe/src/contractSupport/zoeHelpers.js'; +import { makeZoeTools } from '@agoric/orchestration/src/utils/zoe-tools.js'; import { prepareAdvancer } from './exos/advancer.js'; import { prepareLiquidityPoolKit } from './exos/liquidity-pool.js'; import { prepareSettler } from './exos/settler.js'; @@ -74,6 +75,7 @@ export const contract = async (zcf, privateArgs, zone, tools) => { const statusManager = prepareStatusManager(zone); const makeSettler = prepareSettler(zone, { statusManager }); const { chainHub, vowTools } = tools; + const zoeTools = makeZoeTools(zcf, vowTools); const makeAdvancer = prepareAdvancer(zone, { chainHub, feeConfig, @@ -84,6 +86,8 @@ export const contract = async (zcf, privateArgs, zone, tools) => { }), statusManager, vowTools, + zcf, + zoeTools, }); const makeFeedKit = prepareTransactionFeedKit(zone, zcf); assertAllDefined({ makeFeedKit, makeAdvancer, makeSettler, statusManager }); diff --git a/packages/fast-usdc/test/exos/advancer.test.ts b/packages/fast-usdc/test/exos/advancer.test.ts index b3ca8f0e050..fb21deabf86 100644 --- a/packages/fast-usdc/test/exos/advancer.test.ts +++ b/packages/fast-usdc/test/exos/advancer.test.ts @@ -7,6 +7,7 @@ import fetchedChainInfo from '@agoric/orchestration/src/fetched-chain-info.js'; import { Far } from '@endo/pass-style'; import { makePromiseKit } from '@endo/promise-kit'; import type { NatAmount } from '@agoric/ertp'; +import { type ZoeTools } from '@agoric/orchestration/src/utils/zoe-tools.js'; import { PendingTxStatus } from '../../src/constants.js'; import { prepareAdvancer } from '../../src/exos/advancer.js'; import { prepareStatusManager } from '../../src/exos/status-manager.js'; @@ -50,10 +51,27 @@ const createTestExtensions = (t, common: CommonSetup) => { usdc, }); + const mockZCF = Far('MockZCF', { + makeEmptySeatKit: () => ({ zcfSeat: Far('MockZCFSeat', {}) }), + }); + + const localTransferVK = vowTools.makeVowKit(); + const resolveLocalTransferV = () => { + // pretend funds move from tmpSeat to poolAccount + localTransferVK.resolver.resolve(); + }; + const mockZoeTools = Far('MockZoeTools', { + localTransfer(...args: Parameters) { + console.log('ZoeTools.localTransfer called with', args); + return localTransferVK.vow; + }, + }); + const feeConfig = makeTestFeeConfig(usdc); const makeAdvancer = prepareAdvancer(rootZone.subZone('advancer'), { chainHub, feeConfig, + localTransfer: mockZoeTools.localTransfer, log, statusManager, usdc: harden({ @@ -61,6 +79,8 @@ const createTestExtensions = (t, common: CommonSetup) => { denom: LOCAL_DENOM, }), vowTools, + // @ts-expect-error mocked zcf + zcf: mockZCF, }); /** pretend we have 1M USDC in pool deposits */ @@ -73,19 +93,19 @@ const createTestExtensions = (t, common: CommonSetup) => { mockPoolBalance = usdc.make(value); }; - const borrowUnderlyingPK = makePromiseKit>(); - const resolveBorrowUnderlyingP = async (amount: Amount<'nat'>) => { - const pmt = await pourPayment(amount); - return borrowUnderlyingPK.resolve(pmt); + const borrowUnderlyingPK = makePromiseKit(); + const resolveBorrowUnderlyingP = () => { + // pretend funds are allocated to tmpSeat provided to borrow + return borrowUnderlyingPK.resolve(); }; const rejectBorrowUnderlyingP = () => borrowUnderlyingPK.reject('Mock unable to borrow.'); const advancer = makeAdvancer({ - assetManagerFacet: Far('AssetManager', { - lookupBalance: () => mockPoolBalance, - borrow: (amount: NatAmount) => { - t.log('borrowUnderlying called with', amount); + borrowerFacet: Far('LiquidityPool Borrow Facet', { + getBalance: () => mockPoolBalance, + borrow: (seat: ZCFSeat, amounts: { USDC: NatAmount }) => { + t.log('borrowUnderlying called with', amounts); return borrowUnderlyingPK.promise; }, repay: () => Promise.resolve(), @@ -106,6 +126,7 @@ const createTestExtensions = (t, common: CommonSetup) => { setMockPoolBalance, resolveBorrowUnderlyingP, rejectBorrowUnderlyingP, + resolveLocalTransferV, }, services: { advancer, @@ -134,7 +155,11 @@ test('updates status to ADVANCED in happy path', async t => { extensions: { services: { advancer, statusManager }, helpers: { inspectLogs }, - mocks: { mockPoolAccount, resolveBorrowUnderlyingP }, + mocks: { + mockPoolAccount, + resolveBorrowUnderlyingP, + resolveLocalTransferV, + }, }, brands: { usdc }, } = t.context; @@ -142,7 +167,8 @@ test('updates status to ADVANCED in happy path', async t => { const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); const handleTxP = advancer.handleTransactionEvent(mockEvidence); - await resolveBorrowUnderlyingP(usdc.make(mockEvidence.tx.amount)); + resolveBorrowUnderlyingP(); + resolveLocalTransferV(); await eventLoopIteration(); mockPoolAccount.transferVResolver.resolve(); @@ -162,7 +188,7 @@ test('updates status to ADVANCED in happy path', async t => { t.deepEqual(inspectLogs(0), [ 'Advance transfer fulfilled', - '{"amount":{"brand":"[Alleged: USDC brand]","value":"[150000000n]"},"destination":{"chainId":"osmosis-1","encoding":"bech32","value":"osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men"},"result":"[undefined]"}', + '{"amount":{"brand":"[Alleged: USDC brand]","value":"[146999999n]"},"destination":{"chainId":"osmosis-1","encoding":"bech32","value":"osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men"},"result":"[undefined]"}', ]); }); @@ -211,8 +237,8 @@ test('updates status to OBSERVED if balance query fails', async t => { // make a new advancer that intentionally throws const advancer = makeAdvancer({ // @ts-expect-error mock - assetManagerFacet: Far('AssetManager', { - lookupBalance: () => { + borrowerFacet: Far('LiquidityPool Borrow Facet', { + getBalance: () => { throw new Error('lookupBalance failed'); }, }), @@ -267,13 +293,17 @@ test('updates status to OBSERVED if makeChainAddress fails', async t => { ]); }); -// TODO, this failure should be handled differently +// TODO #10510 this failure should be handled differently test('does not update status on failed transfer', async t => { const { extensions: { services: { advancer, statusManager }, helpers: { inspectLogs }, - mocks: { mockPoolAccount, resolveBorrowUnderlyingP }, + mocks: { + mockPoolAccount, + resolveBorrowUnderlyingP, + resolveLocalTransferV, + }, }, brands: { usdc }, } = t.context; @@ -281,7 +311,8 @@ test('does not update status on failed transfer', async t => { const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); const handleTxP = advancer.handleTransactionEvent(mockEvidence); - await resolveBorrowUnderlyingP(usdc.make(mockEvidence.tx.amount)); + resolveBorrowUnderlyingP(); + resolveLocalTransferV(); mockPoolAccount.transferVResolver.reject(new Error('simulated error')); await handleTxP; @@ -340,23 +371,27 @@ test('will not advance same txHash:chainId evidence twice', async t => { extensions: { services: { advancer }, helpers: { inspectLogs }, - mocks: { mockPoolAccount, resolveBorrowUnderlyingP }, + mocks: { + mockPoolAccount, + resolveBorrowUnderlyingP, + resolveLocalTransferV, + }, }, - brands: { usdc }, } = t.context; const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); // First attempt const handleTxP = advancer.handleTransactionEvent(mockEvidence); - await resolveBorrowUnderlyingP(usdc.make(mockEvidence.tx.amount)); + resolveBorrowUnderlyingP(); + resolveLocalTransferV(); mockPoolAccount.transferVResolver.resolve(); await handleTxP; await eventLoopIteration(); t.deepEqual(inspectLogs(0), [ 'Advance transfer fulfilled', - '{"amount":{"brand":"[Alleged: USDC brand]","value":"[150000000n]"},"destination":{"chainId":"osmosis-1","encoding":"bech32","value":"osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men"},"result":"[undefined]"}', + '{"amount":{"brand":"[Alleged: USDC brand]","value":"[146999999n]"},"destination":{"chainId":"osmosis-1","encoding":"bech32","value":"osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men"},"result":"[undefined]"}', ]); // Second attempt @@ -367,3 +402,5 @@ test('will not advance same txHash:chainId evidence twice', async t => { '"[Error: Transaction already seen: \\"seenTx:[\\\\\\"0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702\\\\\\",1]\\"]"', ]); }); + +test.todo('zoeTools.localTransfer fails to deposit borrowed USDC to LOA');