diff --git a/packages/fast-usdc/src/exos/advancer.js b/packages/fast-usdc/src/exos/advancer.js index 9620d93aa9c7..31529de56bac 100644 --- a/packages/fast-usdc/src/exos/advancer.js +++ b/packages/fast-usdc/src/exos/advancer.js @@ -63,7 +63,6 @@ const AdvancerKitI = harden({ onRejected: M.call(M.error(), AdvancerVowCtxShape).returns(), }), transferHandler: M.interface('TransferHandlerI', { - // TODO confirm undefined, and not bigint (sequence) onFulfilled: M.call(M.undefined(), AdvancerVowCtxShape).returns( M.undefined(), ), @@ -152,9 +151,7 @@ export const prepareAdvancerKit = ( statusManager.skipAdvance(evidence, risk.risksIdentified); return; } - - const { borrowerFacet, poolAccount, settlementAddress } = - this.state; + const { settlementAddress } = this.state; const { recipientAddress } = evidence.aux; const decoded = decodeAddressHook(recipientAddress); mustMatch(decoded, AddressHookShape); @@ -167,6 +164,14 @@ export const prepareAdvancerKit = ( const destination = chainHub.makeChainAddress(EUD); const fullAmount = toAmount(evidence.tx.amount); + const { borrowerFacet, notifyFacet, poolAccount } = this.state; + // do not advance if we've already received a mint/settlement + const mintedEarly = notifyFacet.checkMintedEarly( + evidence, + destination, + ); + if (mintedEarly) return; + // throws if requested does not exceed fees const advanceAmount = feeTools.calculateAdvance(fullAmount); @@ -208,7 +213,7 @@ export const prepareAdvancerKit = ( */ onFulfilled(result, ctx) { const { poolAccount, intermediateRecipient } = this.state; - const { destination, advanceAmount, ...detail } = ctx; + const { destination, advanceAmount, tmpSeat: _, ...detail } = ctx; const transferV = E(poolAccount).transfer( destination, { denom: usdc.denom, value: advanceAmount.value }, @@ -273,7 +278,8 @@ export const prepareAdvancerKit = ( onRejected(error, ctx) { const { notifyFacet } = this.state; log('Advance transfer rejected', error); - notifyFacet.notifyAdvancingResult(ctx, false); + const { advanceAmount: _, ...restCtx } = ctx; + notifyFacet.notifyAdvancingResult(restCtx, false); }, }, }, diff --git a/packages/fast-usdc/src/exos/settler.js b/packages/fast-usdc/src/exos/settler.js index 7b3b3de2e6f0..47344a17fe20 100644 --- a/packages/fast-usdc/src/exos/settler.js +++ b/packages/fast-usdc/src/exos/settler.js @@ -8,7 +8,11 @@ import { M } from '@endo/patterns'; import { decodeAddressHook } from '@agoric/cosmic-proto/address-hooks.js'; import { PendingTxStatus } from '../constants.js'; import { makeFeeTools } from '../utils/fees.js'; -import { EvmHashShape } from '../type-guards.js'; +import { + CctpTxEvidenceShape, + EvmHashShape, + makeNatAmountShape, +} from '../type-guards.js'; /** * @import {FungibleTokenPacketData} from '@agoric/cosmic-proto/ibc/applications/transfer/v2/packet.js'; @@ -18,7 +22,7 @@ import { EvmHashShape } from '../type-guards.js'; * @import {Zone} from '@agoric/zone'; * @import {HostOf, HostInterface} from '@agoric/async-flow'; * @import {TargetRegistration} from '@agoric/vats/src/bridge-target.js'; - * @import {NobleAddress, LiquidityPoolKit, FeeConfig, EvmHash, LogFn} from '../types.js'; + * @import {NobleAddress, LiquidityPoolKit, FeeConfig, EvmHash, LogFn, CctpTxEvidence} from '../types.js'; * @import {StatusManager} from './status-manager.js'; */ @@ -31,6 +35,15 @@ import { EvmHashShape } from '../type-guards.js'; const makeMintedEarlyKey = (addr, amount) => `pendingTx:${JSON.stringify([addr, String(amount)])}`; +/** @param {Brand<'nat'>} USDC */ +export const makeAdvanceDetailsShape = USDC => + harden({ + destination: ChainAddressShape, + forwardingAddress: M.string(), + fullAmount: makeNatAmountShape(USDC), + txHash: EvmHashShape, + }); + /** * @param {Zone} zone * @param {object} caps @@ -69,24 +82,21 @@ export const prepareSettler = ( }), notify: M.interface('SettlerNotifyI', { notifyAdvancingResult: M.call( - M.record(), // XXX fill in details TODO + makeAdvanceDetailsShape(USDC), M.boolean(), ).returns(), + checkMintedEarly: M.call( + CctpTxEvidenceShape, + ChainAddressShape, + ).returns(M.boolean()), }), self: M.interface('SettlerSelfI', { - disburse: M.call(EvmHashShape, M.string(), M.nat()).returns( - M.promise(), - ), - forward: M.call( - M.opt(EvmHashShape), - M.string(), - M.nat(), - M.string(), - ).returns(), + disburse: M.call(EvmHashShape, M.nat()).returns(M.promise()), + forward: M.call(EvmHashShape, M.nat(), M.string()).returns(), }), transferHandler: M.interface('SettlerTransferI', { - onFulfilled: M.call(M.any(), M.record()).returns(), - onRejected: M.call(M.any(), M.record()).returns(), + onFulfilled: M.call(M.undefined(), M.string()).returns(), + onRejected: M.call(M.error(), M.string()).returns(), }), }, /** @@ -174,20 +184,24 @@ export const prepareSettler = ( log('dequeued', found, 'for', nfa, amount); switch (found?.status) { case PendingTxStatus.Advanced: - return self.disburse(found.txHash, nfa, amount); + return self.disburse(found.txHash, amount); case PendingTxStatus.Advancing: + log('⚠️ tap: minted while advancing', nfa, amount); this.state.mintedEarly.add(makeMintedEarlyKey(nfa, amount)); return; case PendingTxStatus.Observed: case PendingTxStatus.AdvanceSkipped: case PendingTxStatus.AdvanceFailed: - return self.forward(found.txHash, nfa, amount, EUD); + return self.forward(found.txHash, amount, EUD); case undefined: default: - log('⚠️ tap: no status for ', nfa, amount); + log('⚠️ tap: minted before observed', nfa, amount); + // XXX consider capturing in vstorage + // we would need a new key, as this does not have a txHash + this.state.mintedEarly.add(makeMintedEarlyKey(nfa, amount)); } }, }, @@ -210,16 +224,12 @@ export const prepareSettler = ( const key = makeMintedEarlyKey(forwardingAddress, fullValue); if (mintedEarly.has(key)) { mintedEarly.delete(key); + statusManager.advanceOutcomeForMintedEarly(txHash, success); if (success) { - void this.facets.self.disburse( - txHash, - forwardingAddress, - fullValue, - ); + void this.facets.self.disburse(txHash, fullValue); } else { void this.facets.self.forward( txHash, - forwardingAddress, fullValue, destination.value, ); @@ -228,14 +238,39 @@ export const prepareSettler = ( statusManager.advanceOutcome(forwardingAddress, fullValue, success); } }, + /** + * @param {CctpTxEvidence} evidence + * @param {ChainAddress} destination + * @returns {boolean} + * @throws {Error} if minted early, so advancer doesn't advance + */ + checkMintedEarly(evidence, destination) { + const { + tx: { forwardingAddress, amount }, + txHash, + } = evidence; + const key = makeMintedEarlyKey(forwardingAddress, amount); + const { mintedEarly } = this.state; + if (mintedEarly.has(key)) { + log( + 'matched minted early key, initiating forward', + forwardingAddress, + amount, + ); + mintedEarly.delete(key); + statusManager.advanceOutcomeForUnknownMint(evidence); + void this.facets.self.forward(txHash, amount, destination.value); + return true; + } + return false; + }, }, self: { /** * @param {EvmHash} txHash - * @param {NobleAddress} nfa * @param {NatValue} fullValue */ - async disburse(txHash, nfa, fullValue) { + async disburse(txHash, fullValue) { const { repayer, settlementAccount } = this.state; const received = AmountMath.make(USDC, fullValue); const { zcfSeat: settlingSeat } = zcf.makeEmptySeatKit(); @@ -260,56 +295,43 @@ export const prepareSettler = ( ); repayer.repay(settlingSeat, split); - // update status manager, marking tx `SETTLED` + // update status manager, marking tx `DISBURSED` statusManager.disbursed(txHash, split); }, /** * @param {EvmHash} txHash - * @param {NobleAddress} nfa * @param {NatValue} fullValue * @param {string} EUD */ - forward(txHash, nfa, fullValue, EUD) { + forward(txHash, fullValue, EUD) { const { settlementAccount, intermediateRecipient } = this.state; - const dest = chainHub.makeChainAddress(EUD); - - // TODO? statusManager.forwarding(txHash, sender, amount); const txfrV = E(settlementAccount).transfer( dest, AmountMath.make(USDC, fullValue), { forwardOpts: { intermediateRecipient } }, ); - void vowTools.watch(txfrV, this.facets.transferHandler, { - txHash, - nfa, - fullValue, - }); + void vowTools.watch(txfrV, this.facets.transferHandler, txHash); }, }, transferHandler: { /** * @param {unknown} _result - * @param {SettlerTransferCtx} ctx - * - * @typedef {{ - * txHash: EvmHash; - * nfa: NobleAddress; - * fullValue: NatValue; - * }} SettlerTransferCtx + * @param {EvmHash} txHash */ - onFulfilled(_result, ctx) { - const { txHash, nfa, fullValue } = ctx; - statusManager.forwarded(txHash, nfa, fullValue); + onFulfilled(_result, txHash) { + // update status manager, marking tx `FORWARDED` without fee split + statusManager.forwarded(txHash, true); }, /** * @param {unknown} reason - * @param {SettlerTransferCtx} ctx + * @param {EvmHash} txHash */ - onRejected(reason, ctx) { - log('⚠️ transfer rejected!', reason, ctx); - // const { txHash, nfa, amount } = ctx; - // TODO(#10510): statusManager.forwardFailed(txHash, nfa, amount); + onRejected(reason, txHash) { + log('⚠️ forward transfer rejected!', reason, txHash); + // update status manager, flagging a terminal state that needs to be + // manual intervention or a code update to remediate + statusManager.forwarded(txHash, false); }, }, }, diff --git a/packages/fast-usdc/src/exos/status-manager.js b/packages/fast-usdc/src/exos/status-manager.js index c261ec599a88..9a38e185d24b 100644 --- a/packages/fast-usdc/src/exos/status-manager.js +++ b/packages/fast-usdc/src/exos/status-manager.js @@ -69,14 +69,15 @@ export const prepareStatusManager = ( txnsNode, { marshaller, - log = makeTracer('Advancer', true), + // eslint-disable-next-line no-unused-vars + log = makeTracer('StatusManager', true), } = /** @type {StatusManagerPowers} */ ({}), ) => { /** * Keyed by a tuple of the Noble Forwarding Account and amount. * @type {MapStore} */ - const pendingTxs = zone.mapStore('PendingTxs', { + const pendingSettleTxs = zone.mapStore('PendingSettleTxs', { keyShape: M.string(), valueShape: M.arrayOf(PendingTxShape), }); @@ -156,7 +157,7 @@ export const prepareStatusManager = ( seenTxs.add(txHash); appendToStoredArray( - pendingTxs, + pendingSettleTxs, pendingTxKeyOf(evidence), harden({ ...evidence, status }), ); @@ -177,8 +178,8 @@ export const prepareStatusManager = ( */ function setPendingTxStatus({ nfa, amount }, status) { const key = makePendingTxKey(nfa, amount); - pendingTxs.has(key) || Fail`no advancing tx with ${{ nfa, amount }}`; - const pending = pendingTxs.get(key); + pendingSettleTxs.has(key) || Fail`no advancing tx with ${{ nfa, amount }}`; + const pending = pendingSettleTxs.get(key); const ix = pending.findIndex(tx => tx.status === PendingTxStatus.Advancing); ix >= 0 || Fail`no advancing tx with ${{ nfa, amount }}`; const [prefix, tx, suffix] = [ @@ -187,7 +188,7 @@ export const prepareStatusManager = ( pending.slice(ix + 1), ]; const txpost = { ...tx, status }; - pendingTxs.set(key, harden([...prefix, txpost, ...suffix])); + pendingSettleTxs.set(key, harden([...prefix, txpost, ...suffix])); void publishTxnRecord(tx.txHash, harden({ status })); } @@ -195,24 +196,19 @@ export const prepareStatusManager = ( 'Fast USDC Status Manager', M.interface('StatusManagerI', { // TODO: naming scheme for transition events - advance: M.call(CctpTxEvidenceShape).returns(M.undefined()), + advance: M.call(CctpTxEvidenceShape).returns(), advanceOutcome: M.call(M.string(), M.nat(), M.boolean()).returns(), - skipAdvance: M.call(CctpTxEvidenceShape, M.arrayOf(M.string())).returns( - M.undefined(), - ), - observe: M.call(CctpTxEvidenceShape).returns(M.undefined()), + skipAdvance: M.call(CctpTxEvidenceShape, M.arrayOf(M.string())).returns(), + advanceOutcomeForMintedEarly: M.call(EvmHashShape, M.boolean()).returns(), + advanceOutcomeForUnknownMint: M.call(CctpTxEvidenceShape).returns(), + observe: M.call(CctpTxEvidenceShape).returns(), hasBeenObserved: M.call(CctpTxEvidenceShape).returns(M.boolean()), deleteCompletedTxs: M.call().returns(M.undefined()), dequeueStatus: M.call(M.string(), M.bigint()).returns( M.or( { txHash: EvmHashShape, - status: M.or( - PendingTxStatus.Advanced, - PendingTxStatus.AdvanceSkipped, - PendingTxStatus.AdvanceFailed, - PendingTxStatus.Observed, - ), + status: M.or(...Object.values(PendingTxStatus)), }, M.undefined(), ), @@ -220,9 +216,7 @@ export const prepareStatusManager = ( disbursed: M.call(EvmHashShape, AmountKeywordRecordShape).returns( M.undefined(), ), - forwarded: M.call(M.opt(EvmHashShape), M.string(), M.nat()).returns( - M.undefined(), - ), + forwarded: M.call(EvmHashShape, M.boolean()).returns(), lookupPending: M.call(M.string(), M.bigint()).returns( M.arrayOf(PendingTxShape), ), @@ -258,7 +252,7 @@ export const prepareStatusManager = ( }, /** - * Record result of ADVANCING + * Record result of an ADVANCING transaction * * @param {NobleAddress} nfa Noble Forwarding Account * @param {import('@agoric/ertp').NatValue} amount @@ -272,6 +266,43 @@ export const prepareStatusManager = ( ); }, + /** + * If minted while advancing, publish a status update for the advance + * to vstorage. + * + * Does not add or amend `pendingSettleTxs` as this has + * already settled. + * + * @param {EvmHash} txHash + * @param {boolean} success whether the Transfer succeeded + */ + advanceOutcomeForMintedEarly(txHash, success) { + void publishTxnRecord( + txHash, + harden({ + status: success + ? PendingTxStatus.Advanced + : PendingTxStatus.AdvanceFailed, + }), + ); + }, + + /** + * If minted before observed and the evidence is eventually + * reported, publish the evidence without adding to `pendingSettleTxs` + * + * @param {CctpTxEvidence} evidence + */ + advanceOutcomeForUnknownMint(evidence) { + const { txHash } = evidence; + // unexpected path, since `hasBeenObserved` will be called before this + if (seenTxs.has(txHash)) { + throw makeError(`Transaction already seen: ${q(txHash)}`); + } + seenTxs.add(txHash); + publishEvidence(txHash, evidence); + }, + /** * Add a new transaction with OBSERVED status * @param {CctpTxEvidence} evidence @@ -303,34 +334,32 @@ export const prepareStatusManager = ( }, /** - * Remove and return an `ADVANCED` or `OBSERVED` tx waiting to be `SETTLED`. + * Remove and return the oldest pending settlement transaction that matches the given + * forwarding account and amount. Since multiple pending transactions may exist with + * identical (account, amount) pairs, we process them in FIFO order. * * @param {NobleAddress} nfa * @param {bigint} amount - * @returns {Pick | undefined} undefined if nothing - * with this address and amount has been marked pending. + * @returns {Pick | undefined} undefined if no pending + * transactions exist for this address and amount combination. */ dequeueStatus(nfa, amount) { const key = makePendingTxKey(nfa, amount); - if (!pendingTxs.has(key)) return undefined; - const pending = pendingTxs.get(key); + if (!pendingSettleTxs.has(key)) return undefined; + const pending = pendingSettleTxs.get(key); - const dequeueIdx = pending.findIndex( - x => x.status !== PendingTxStatus.Advancing, - ); - if (dequeueIdx < 0) return undefined; + if (pending.length === 0) { + return undefined; + } + // extract first item + const [{ status, txHash }, ...remaining] = pending; - if (pending.length > 1) { - const pendingCopy = [...pending]; - pendingCopy.splice(dequeueIdx, 1); - pendingTxs.set(key, harden(pendingCopy)); + if (remaining.length) { + pendingSettleTxs.set(key, harden(remaining)); } else { - pendingTxs.delete(key); + pendingSettleTxs.delete(key); } - const { status, txHash } = pending[dequeueIdx]; - // TODO: store txHash -> evidence for txs pending settlement? - // If necessary for vstorage writes in `forwarded` and `settled` return harden({ status, txHash }); }, @@ -348,21 +377,18 @@ export const prepareStatusManager = ( }, /** - * Mark a transaction as `FORWARDED` + * Mark a transaction as `FORWARDED` or `FORWARD_FAILED` * - * @param {EvmHash | undefined} txHash - undefined in case mint before observed - * @param {NobleAddress} nfa - * @param {bigint} amount + * @param {EvmHash} txHash + * @param {boolean} success */ - forwarded(txHash, nfa, amount) { - if (txHash) { - void publishTxnRecord(txHash, harden({ status: TxStatus.Forwarded })); - } else { - // TODO store (early) `Minted` transactions to check against incoming evidence - log( - `⚠️ Forwarded minted amount ${amount} from account ${nfa} before it was observed.`, - ); - } + forwarded(txHash, success) { + void publishTxnRecord( + txHash, + harden({ + status: success ? TxStatus.Forwarded : TxStatus.ForwardFailed, + }), + ); }, /** @@ -376,10 +402,10 @@ export const prepareStatusManager = ( */ lookupPending(nfa, amount) { const key = makePendingTxKey(nfa, amount); - if (!pendingTxs.has(key)) { + if (!pendingSettleTxs.has(key)) { return harden([]); } - return pendingTxs.get(key); + return pendingSettleTxs.get(key); }, }, ); diff --git a/packages/fast-usdc/test/exos/advancer.test.ts b/packages/fast-usdc/test/exos/advancer.test.ts index e8ee9c1a6263..c5e53c0b70a6 100644 --- a/packages/fast-usdc/test/exos/advancer.test.ts +++ b/packages/fast-usdc/test/exos/advancer.test.ts @@ -6,16 +6,20 @@ import { } from '@agoric/cosmic-proto/address-hooks.js'; import type { NatAmount } from '@agoric/ertp'; import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; -import { denomHash } from '@agoric/orchestration'; +import { ChainAddressShape, denomHash } from '@agoric/orchestration'; import fetchedChainInfo from '@agoric/orchestration/src/fetched-chain-info.js'; import { type ZoeTools } from '@agoric/orchestration/src/utils/zoe-tools.js'; import { q } from '@endo/errors'; import { Far } from '@endo/pass-style'; import type { TestFn } from 'ava'; import { makeTracer } from '@agoric/internal'; +import { M, mustMatch } from '@endo/patterns'; import { PendingTxStatus } from '../../src/constants.js'; import { prepareAdvancer } from '../../src/exos/advancer.js'; -import type { SettlerKit } from '../../src/exos/settler.js'; +import { + makeAdvanceDetailsShape, + type SettlerKit, +} from '../../src/exos/settler.js'; import { prepareStatusManager } from '../../src/exos/status-manager.js'; import type { LiquidityPoolKit } from '../../src/types.js'; import { makeFeeTools } from '../../src/utils/fees.js'; @@ -30,6 +34,7 @@ import { prepareMockOrchAccounts, } from '../mocks.js'; import { commonSetup } from '../supports.js'; +import { CctpTxEvidenceShape } from '../../src/type-guards.js'; const trace = makeTracer('AdvancerTest', false); @@ -108,8 +113,17 @@ const createTestExtensions = (t, common: CommonSetup) => { const mockNotifyF = Far('Settler Notify Facet', { notifyAdvancingResult: (...args: NotifyArgs) => { trace('Settler.notifyAdvancingResult called with', args); + const [advanceDetails, success] = args; + mustMatch(harden(advanceDetails), makeAdvanceDetailsShape(usdc.brand)); + mustMatch(success, M.boolean()); notifyAdvancingResultCalls.push(args); }, + // assume this never returns true for most tests + checkMintedEarly: (evidence, destination) => { + mustMatch(harden(evidence), CctpTxEvidenceShape); + mustMatch(destination, ChainAddressShape); + return false; + }, }); const mockBorrowerFacetCalls: { @@ -361,7 +375,6 @@ test('calls notifyAdvancingResult (AdvancedFailed) on failed transfer', async t txHash: evidence.txHash, forwardingAddress: evidence.tx.forwardingAddress, fullAmount: usdc.make(evidence.tx.amount), - advanceAmount: feeTools.calculateAdvance(usdc.make(evidence.tx.amount)), destination: { value: decodeAddressHook(evidence.aux.recipientAddress).query.EUD, }, @@ -659,3 +672,45 @@ test('rejects advances to unknown settlementAccount', async t => { ], ]); }); + +test('no status update if `checkMintedEarly` returns true', async t => { + const { + brands: { usdc }, + bootstrap: { storage }, + extensions: { + services: { makeAdvancer }, + helpers: { inspectLogs }, + mocks: { mockPoolAccount, mockBorrowerF }, + }, + } = t.context; + + const mockNotifyF = Far('Settler Notify Facet', { + notifyAdvancingResult: () => {}, + checkMintedEarly: (evidence, destination) => { + return true; + }, + }); + + const advancer = makeAdvancer({ + borrowerFacet: mockBorrowerF, + notifyFacet: mockNotifyF, + poolAccount: mockPoolAccount.account, + intermediateRecipient, + settlementAddress, + }); + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); + void advancer.handleTransactionEvent({ evidence, risk: {} }); + await eventLoopIteration(); + + // advancer does not post a tx status; settler will Forward and + // communicate Forwarded/ForwardFailed status' + t.throws(() => storage.getDeserialized(`fun.txns.${evidence.txHash}`), { + message: /no data at path fun.txns.0x/, + }); + + t.deepEqual(inspectLogs(), [ + ['decoded EUD: dydx183dejcnmkka5dzcu9xw6mywq0p2m5peks28men'], + // no add'l logs as we return early + ]); +}); diff --git a/packages/fast-usdc/test/exos/settler.test.ts b/packages/fast-usdc/test/exos/settler.test.ts index 5a8d9928385f..4bed4311dae8 100644 --- a/packages/fast-usdc/test/exos/settler.test.ts +++ b/packages/fast-usdc/test/exos/settler.test.ts @@ -1,12 +1,13 @@ import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import type { TestFn } from 'ava'; +import { decodeAddressHook } from '@agoric/cosmic-proto/address-hooks.js'; +import { defaultMarshaller } from '@agoric/internal/src/storage-test-utils.js'; import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; import fetchedChainInfo from '@agoric/orchestration/src/fetched-chain-info.js'; import type { Zone } from '@agoric/zone'; -import { defaultMarshaller } from '@agoric/internal/src/storage-test-utils.js'; -import { PendingTxStatus } from '../../src/constants.js'; -import { prepareSettler } from '../../src/exos/settler.js'; +import { PendingTxStatus, TxStatus } from '../../src/constants.js'; +import { prepareSettler, type SettlerKit } from '../../src/exos/settler.js'; import { prepareStatusManager } from '../../src/exos/status-manager.js'; import type { CctpTxEvidence } from '../../src/types.js'; import { makeFeeTools } from '../../src/utils/fees.js'; @@ -95,45 +96,96 @@ const makeTestContext = async t => { intermediateRecipient, }); - const simulate = harden({ - advance: (evidence?: CctpTxEvidence) => { - const cctpTxEvidence: CctpTxEvidence = { - ...MockCctpTxEvidences.AGORIC_PLUS_OSMO(), - ...evidence, - }; - t.log('Mock CCTP Evidence:', cctpTxEvidence); - t.log('Pretend we initiated advance, mark as `ADVANCED`'); - statusManager.advance(cctpTxEvidence); - const { forwardingAddress, amount } = cctpTxEvidence.tx; - statusManager.advanceOutcome(forwardingAddress, BigInt(amount), true); - - return cctpTxEvidence; - }, - - skipAdvance: (risksIdentified: string[], evidence?: CctpTxEvidence) => { - const cctpTxEvidence: CctpTxEvidence = { - ...MockCctpTxEvidences.AGORIC_PLUS_OSMO(), - ...evidence, - }; - t.log('Mock CCTP Evidence:', cctpTxEvidence); - t.log('Mark as `ADVANCE_SKIPPED`'); - statusManager.skipAdvance(cctpTxEvidence, risksIdentified ?? []); - - return cctpTxEvidence; - }, - - observe: (evidence?: CctpTxEvidence) => { - const cctpTxEvidence: CctpTxEvidence = { + const makeSimulate = (notifyFacet: SettlerKit['notify']) => { + const makeEvidence = (evidence?: CctpTxEvidence): CctpTxEvidence => + harden({ ...MockCctpTxEvidences.AGORIC_PLUS_OSMO(), ...evidence, - }; - t.log('Mock CCTP Evidence:', cctpTxEvidence); - t.log('Pretend we `OBSERVED`'); - statusManager.observe(cctpTxEvidence); + }); + + const makeNotifyInfo = (evidence: CctpTxEvidence) => { + const { txHash } = evidence; + const { forwardingAddress, amount } = evidence.tx; + const { recipientAddress } = evidence.aux; + const { EUD } = decodeAddressHook(recipientAddress).query; + if (typeof EUD !== 'string') { + throw Error(`EUD not found in ${recipientAddress}`); + } + const destination = chainHub.makeChainAddress(EUD); + return harden({ + txHash, + forwardingAddress, + fullAmount: usdc.make(amount), + destination, + }); + }; + + const simulate = harden({ + /** + * simulate Advancer starting advance + * @param evidence + */ + startAdvance: (evidence?: CctpTxEvidence) => { + const cctpTxEvidence = makeEvidence(evidence); + t.log('Mock CCTP Evidence:', cctpTxEvidence); + t.log('Pretend we initiated advance, mark as `ADVANCING`'); + statusManager.advance(cctpTxEvidence); + return cctpTxEvidence; + }, + /** + * Simulate transfer vow settlement (success or failure) + * @param evidence + * @param success + */ + finishAdvance: (evidence: CctpTxEvidence, success = true) => { + const { Advanced, AdvanceFailed } = TxStatus; + t.log(`Simulate ${success ? Advanced : AdvanceFailed}`); + const info = makeNotifyInfo(evidence); + notifyFacet.notifyAdvancingResult(info, success); + }, + /** + * start and finish advance successfully + * @param evidence + */ + advance: (evidence?: CctpTxEvidence) => { + const cctpTxEvidence = simulate.startAdvance(evidence); + simulate.finishAdvance(cctpTxEvidence, true); + return cctpTxEvidence; + }, + skipAdvance: (risksIdentified: string[], evidence?: CctpTxEvidence) => { + const cctpTxEvidence = makeEvidence(evidence); + t.log('Mock CCTP Evidence:', cctpTxEvidence); + t.log('Mark as `ADVANCE_SKIPPED`'); + statusManager.skipAdvance(cctpTxEvidence, risksIdentified ?? []); - return cctpTxEvidence; - }, - }); + return cctpTxEvidence; + }, + /** + * slow path - e.g. insufficient pool funds + * @param evidence + */ + observe: (evidence?: CctpTxEvidence) => { + const cctpTxEvidence = makeEvidence(evidence); + t.log('Mock CCTP Evidence:', cctpTxEvidence); + t.log('Pretend we `OBSERVED` (did not advance)'); + statusManager.observe(cctpTxEvidence); + + return cctpTxEvidence; + }, + /** + * mint early path. caller must simulate tap before calling + * @param evidence + */ + observeLate: (evidence?: CctpTxEvidence) => { + const cctpTxEvidence = makeEvidence(evidence); + const { destination, forwardingAddress, fullAmount, txHash } = + makeNotifyInfo(cctpTxEvidence); + notifyFacet.checkMintedEarly(cctpTxEvidence, destination); + return cctpTxEvidence; + }, + }); + return simulate; + }; const repayer = zone.exo('Repayer Mock', undefined, { repay(fromSeat: ZCFSeat, amounts: AmountKeywordRecord) { @@ -146,7 +198,7 @@ const makeTestContext = async t => { makeSettler, statusManager, defaultSettlerParams, - simulate, + makeSimulate, repayer, peekCalls: () => harden([...callLog]), inspectLogs, @@ -166,7 +218,7 @@ test('happy path: disburse to LPs; StatusManager removes tx', async t => { statusManager, defaultSettlerParams, repayer, - simulate, + makeSimulate, accounts, peekCalls, } = t.context; @@ -178,7 +230,7 @@ test('happy path: disburse to LPs; StatusManager removes tx', async t => { settlementAccount: accounts.settlement.account, ...defaultSettlerParams, }); - + const simulate = makeSimulate(settler.notify); const cctpTxEvidence = simulate.advance(); t.deepEqual( statusManager.lookupPending( @@ -268,7 +320,7 @@ test('slow path: forward to EUD; remove pending tx', async t => { statusManager, defaultSettlerParams, repayer, - simulate, + makeSimulate, accounts, peekCalls, } = t.context; @@ -279,7 +331,7 @@ test('slow path: forward to EUD; remove pending tx', async t => { settlementAccount: accounts.settlement.account, ...defaultSettlerParams, }); - + const simulate = makeSimulate(settler.notify); const cctpTxEvidence = simulate.observe(); t.deepEqual( statusManager.lookupPending( @@ -323,9 +375,11 @@ test('slow path: forward to EUD; remove pending tx', async t => { cctpTxEvidence.tx.amount, ), [], - 'SETTLED entry removed from StatusManger', + 'dequeueStatus entry removed from StatusManger', ); const { storage } = t.context; + accounts.settlement.transferVResolver.resolve(undefined); + await eventLoopIteration(); t.deepEqual(storage.getDeserialized(`fun.txns.${cctpTxEvidence.txHash}`), [ { evidence: cctpTxEvidence, status: 'OBSERVED' }, { status: 'FORWARDED' }, @@ -344,7 +398,7 @@ test('skip advance: forward to EUD; remove pending tx', async t => { statusManager, defaultSettlerParams, repayer, - simulate, + makeSimulate, accounts, peekCalls, } = t.context; @@ -356,6 +410,7 @@ test('skip advance: forward to EUD; remove pending tx', async t => { ...defaultSettlerParams, }); + const simulate = makeSimulate(settler.notify); const cctpTxEvidence = simulate.skipAdvance(['TOO_LARGE_AMOUNT']); t.deepEqual( statusManager.lookupPending( @@ -392,6 +447,8 @@ test('skip advance: forward to EUD; remove pending tx', async t => { }, ], ]); + accounts.settlement.transferVResolver.resolve(undefined); + await eventLoopIteration(); t.deepEqual( statusManager.lookupPending( @@ -414,15 +471,19 @@ test('skip advance: forward to EUD; remove pending tx', async t => { t.is(storage.data.get(`fun.txns.${cctpTxEvidence.txHash}`), undefined); }); -test('Settlement for unknown transaction', async t => { +test('Settlement for unknown transaction (minted early)', async t => { const { - common, + common: { + brands: { usdc }, + }, makeSettler, defaultSettlerParams, repayer, accounts, peekCalls, inspectLogs, + makeSimulate, + storage, } = t.context; const settler = makeSettler({ @@ -430,24 +491,241 @@ test('Settlement for unknown transaction', async t => { settlementAccount: accounts.settlement.account, ...defaultSettlerParams, }); + const simulate = makeSimulate(settler.notify); t.log('Simulate incoming IBC settlement'); void settler.tap.receiveUpcall(MockVTransferEvents.AGORIC_PLUS_OSMO()); await eventLoopIteration(); - t.log('Nothing was transferrred'); + t.log('Nothing was transferred'); t.deepEqual(peekCalls(), []); t.deepEqual(accounts.settlement.callLog, []); - t.like(inspectLogs(), [ + const tapLogs = inspectLogs(); + t.like(tapLogs, [ ['config', { sourceChannel: 'channel-21' }], ['upcall event'], ['dequeued', undefined], + ['⚠️ tap: minted before observed'], + ]); + + t.log('Oracle operators eventually report...'); + const evidence = simulate.observeLate(); + t.deepEqual(inspectLogs().slice(tapLogs.length - 1), [ [ - '⚠️ tap: no status for ', + 'matched minted early key, initiating forward', 'noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelqkd', 150000000n, ], ]); + await eventLoopIteration(); + t.like(accounts.settlement.callLog, [ + [ + 'transfer', + { + value: 'osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + }, + usdc.units(150), + { + forwardOpts: { + intermediateRecipient: { + value: 'noble1test', + }, + }, + }, + ], + ]); + accounts.settlement.transferVResolver.resolve(undefined); + await eventLoopIteration(); + t.deepEqual(storage.getDeserialized(`fun.txns.${evidence.txHash}`), [ + { evidence, status: 'OBSERVED' }, + { status: 'FORWARDED' }, + ]); +}); + +test('Settlement for Advancing transaction (advance succeeds)', async t => { + const { + accounts, + defaultSettlerParams, + inspectLogs, + makeSettler, + repayer, + makeSimulate, + statusManager, + common: { + brands: { usdc }, + commonPrivateArgs: { feeConfig }, + }, + storage, + } = t.context; + + const settler = makeSettler({ + repayer, + settlementAccount: accounts.settlement.account, + ...defaultSettlerParams, + }); + const simulate = makeSimulate(settler.notify); + const cctpTxEvidence = simulate.startAdvance(); + const { forwardingAddress, amount } = cctpTxEvidence.tx; + + t.deepEqual( + statusManager.lookupPending(forwardingAddress, amount), + [{ ...cctpTxEvidence, status: PendingTxStatus.Advancing }], + 'statusManager shows this tx is Advancing', + ); + + t.log('Simulate incoming IBC settlement'); + void settler.tap.receiveUpcall(MockVTransferEvents.AGORIC_PLUS_OSMO()); + await eventLoopIteration(); + + t.like(inspectLogs(), [ + ['config', { sourceChannel: 'channel-21' }], + ['upcall event'], + ['dequeued', { status: PendingTxStatus.Advancing }], + ['⚠️ tap: minted while advancing'], + ]); + + const In = usdc.make(MockCctpTxEvidences.AGORIC_PLUS_OSMO().tx.amount); + const expectedSplit = makeFeeTools(feeConfig).calculateSplit(In); + + t.log('Simulate advance success'); + simulate.finishAdvance(cctpTxEvidence, true); + await eventLoopIteration(); + t.deepEqual(storage.getDeserialized(`fun.txns.${cctpTxEvidence.txHash}`), [ + { evidence: cctpTxEvidence, status: 'OBSERVED' }, + { status: 'ADVANCING' }, + { status: 'ADVANCED' }, + { split: expectedSplit, status: 'DISBURSED' }, + ]); }); -test.todo("StatusManager does not receive update when we can't settle"); +test('Settlement for Advancing transaction (advance fails)', async t => { + const { + accounts, + defaultSettlerParams, + inspectLogs, + makeSettler, + repayer, + makeSimulate, + common: { + brands: { usdc }, + }, + storage, + } = t.context; + + const settler = makeSettler({ + repayer, + settlementAccount: accounts.settlement.account, + ...defaultSettlerParams, + }); + const simulate = makeSimulate(settler.notify); + const cctpTxEvidence = simulate.startAdvance(); + + t.log('Simulate incoming IBC settlement'); + void settler.tap.receiveUpcall(MockVTransferEvents.AGORIC_PLUS_OSMO()); + await eventLoopIteration(); + + t.like(inspectLogs(), [ + ['config', { sourceChannel: 'channel-21' }], + ['upcall event'], + ['dequeued', { status: PendingTxStatus.Advancing }], + ['⚠️ tap: minted while advancing'], + ]); + + t.log('Simulate Advance failure (e.g. IBC Timeout)'); + simulate.finishAdvance(cctpTxEvidence, false); + await eventLoopIteration(); + + t.log('Expecting Settler to initiate forward'); + t.like(accounts.settlement.callLog, [ + [ + 'transfer', + { + value: 'osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + }, + usdc.units(150), + { + forwardOpts: { + intermediateRecipient: { + value: 'noble1test', + }, + }, + }, + ], + ]); + + t.log('Pretend Forward succeeds'); + accounts.settlement.transferVResolver.resolve(undefined); + await eventLoopIteration(); + t.deepEqual(storage.getDeserialized(`fun.txns.${cctpTxEvidence.txHash}`), [ + { evidence: cctpTxEvidence, status: 'OBSERVED' }, + { status: 'ADVANCING' }, + { status: 'ADVANCE_FAILED' }, + { status: 'FORWARDED' }, + ]); +}); + +test('slow path, and forward fails (terminal state)', async t => { + const { + common, + makeSettler, + statusManager, + defaultSettlerParams, + repayer, + makeSimulate, + accounts, + peekCalls, + storage, + } = t.context; + const { usdc } = common.brands; + + const settler = makeSettler({ + repayer, + settlementAccount: accounts.settlement.account, + ...defaultSettlerParams, + }); + const simulate = makeSimulate(settler.notify); + const cctpTxEvidence = simulate.observe(); + t.deepEqual( + statusManager.lookupPending( + cctpTxEvidence.tx.forwardingAddress, + cctpTxEvidence.tx.amount, + ), + [{ ...cctpTxEvidence, status: PendingTxStatus.Observed }], + 'statusManager shows this tx is only observed', + ); + + t.log('Simulate incoming IBC settlement'); + void settler.tap.receiveUpcall(MockVTransferEvents.AGORIC_PLUS_OSMO()); + await eventLoopIteration(); + + t.log('funds are forwarded; no interaction with LP'); + t.like(accounts.settlement.callLog, [ + [ + 'transfer', + { + value: 'osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + }, + usdc.units(150), + { + forwardOpts: { + intermediateRecipient: { + value: 'noble1test', + }, + }, + }, + ], + ]); + + t.log('simulating forward failure (e.g. unknown route)'); + const mockE = Error('no connection info found'); + accounts.settlement.transferVResolver.reject(mockE); + await eventLoopIteration(); + t.deepEqual(storage.getDeserialized(`fun.txns.${cctpTxEvidence.txHash}`), [ + { evidence: cctpTxEvidence, status: 'OBSERVED' }, + { status: 'FORWARD_FAILED' }, + ]); +}); + +test.todo('creator facet methods'); + +test.todo('ignored packets'); diff --git a/packages/fast-usdc/test/exos/status-manager.test.ts b/packages/fast-usdc/test/exos/status-manager.test.ts index 2c76fefa205e..2f2ebcf25c1c 100644 --- a/packages/fast-usdc/test/exos/status-manager.test.ts +++ b/packages/fast-usdc/test/exos/status-manager.test.ts @@ -277,27 +277,24 @@ test('dequeueStatus returns first (earliest) matched entry', async t => { statusManager.advance(evidence); statusManager.advance({ ...evidence, txHash: '0xtest2' }); - // cannot dequeue ADVANCING pendingTx - t.is( + t.like( statusManager.dequeueStatus( evidence.tx.forwardingAddress, evidence.tx.amount, ), - undefined, + { + status: PendingTxStatus.Advancing, + }, + 'can dequeue Tx at any stage', ); - statusManager.advanceOutcome( - evidence.tx.forwardingAddress, - evidence.tx.amount, - true, - ); statusManager.advanceOutcome( evidence.tx.forwardingAddress, evidence.tx.amount, true, ); - // also can dequeue OBSERVED statuses + // can dequeue OBSERVED statuses statusManager.observe({ ...evidence, txHash: '0xtest3' }); // dequeue will return the first match @@ -314,28 +311,13 @@ test('dequeueStatus returns first (earliest) matched entry', async t => { evidence.tx.forwardingAddress, evidence.tx.amount, ); - t.is(entries0.length, 2); - t.is( - entries0?.[0].status, - PendingTxStatus.Advanced, - 'first settled entry deleted', - ); + t.is(entries0.length, 1); t.deepEqual( - entries0?.[1].status, + entries0?.[0].status, PendingTxStatus.Observed, 'order of remaining entries preserved', ); - // dequeue again wih same args to settle 2nd advance - t.like( - statusManager.dequeueStatus( - evidence.tx.forwardingAddress, - evidence.tx.amount, - ), - { - status: 'ADVANCED', - }, - ); // dequeue again wih same ags to settle remaining observe t.like( statusManager.dequeueStatus( diff --git a/packages/fast-usdc/test/fast-usdc.contract.test.ts b/packages/fast-usdc/test/fast-usdc.contract.test.ts index 9d333a40531b..a7e481496714 100644 --- a/packages/fast-usdc/test/fast-usdc.contract.test.ts +++ b/packages/fast-usdc/test/fast-usdc.contract.test.ts @@ -6,7 +6,6 @@ import { encodeAddressHook, } from '@agoric/cosmic-proto/address-hooks.js'; import { AmountMath } from '@agoric/ertp/src/amountMath.js'; -import type { makeFakeStorageKit } from '@agoric/internal/src/storage-test-utils.js'; import { eventLoopIteration, inspectMapStore, @@ -137,11 +136,13 @@ const makeTestContext = async (t: ExecutionContext) => { return E(purse).deposit(pmt); }; + const accountsData = common.bootstrap.storage.data.get('fun'); + const { settlementAccount, poolAccount } = JSON.parse( + JSON.parse(accountsData!).values[0], + ); + t.is(settlementAccount, 'agoric1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc09z0g'); + t.is(poolAccount, 'agoric1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp7zqht'); const mint = async (e: CctpTxEvidence) => { - const accountsData = common.bootstrap.storage.data.get('fun'); - const { settlementAccount } = JSON.parse( - JSON.parse(accountsData!).values[0], - ); const rxd = await receiveUSDCAt(settlementAccount, e.tx.amount); await VE(transferBridge).fromBridge( buildVTransferEvent({ @@ -157,7 +158,15 @@ const makeTestContext = async (t: ExecutionContext) => { return rxd; }; - return { bridges: { snapshot, since }, common, evm, mint, startKit, sync }; + return { + bridges: { snapshot, since }, + common, + evm, + mint, + startKit, + sync, + addresses: { settlementAccount, poolAccount }, + }; }; type FucContext = Awaited>; @@ -357,13 +366,17 @@ const makeLP = async ( const makeEVM = (template = MockCctpTxEvidences.AGORIC_PLUS_OSMO()) => { let nonce = 0; - const makeTx = (amount: bigint, recipientAddress: string): CctpTxEvidence => { + const makeTx = ( + amount: bigint, + recipientAddress: string, + nonceOverride?: number, + ): CctpTxEvidence => { nonce += 1; const tx: CctpTxEvidence = harden({ ...template, - txHash: `0x00000${nonce}`, - blockNumber: template.blockNumber + BigInt(nonce), + txHash: `0x00000${nonceOverride || nonce}`, + blockNumber: template.blockNumber + BigInt(nonceOverride || nonce), tx: { ...template.tx, amount }, // KLUDGE: CCTP doesn't know about aux; it would be added by OCW aux: { ...template.aux, recipientAddress }, @@ -412,6 +425,7 @@ const makeCustomer = ( amount: bigint, EUD: string, isRisk = false, + nonceOverride?: number, ) => { const { storage } = t.context.common.bootstrap; const accountsData = storage.data.get('fun'); @@ -421,7 +435,7 @@ const makeCustomer = ( const recipientAddress = encodeAddressHook(settlementAccount, { EUD }); // KLUDGE: UI would ask noble for a forwardingAddress // "cctp" here has some noble stuff mixed in. - const tx = cctp.makeTx(amount, recipientAddress); + const tx = cctp.makeTx(amount, recipientAddress, nonceOverride); t.log(who, 'signs CCTP for', amount, 'uusdc w/EUD:', EUD); txPublisher.publish({ evidence: tx, isRisk }); sent.push({ evidence: tx, isRisk }); @@ -429,7 +443,7 @@ const makeCustomer = ( return tx; }, checkSent: ( - t: ExecutionContext, + t: ExecutionContext, { bank = [] as any[], local = [] as any[] } = {}, forward?: unknown, ) => { @@ -467,6 +481,8 @@ const makeCustomer = ( if (forward) return; throw t.fail(`no MsgTransfer to ${EUD}`); } + const { poolAccount } = t.context.addresses; + t.is(myMsg.address, poolAccount, 'advance sent from pool account'); const [ibcTransferMsg] = myMsg.messages; // C4 - Contract MUST release funds to the end user destination address // in response to invocation by the off-chain watcher that @@ -476,7 +492,7 @@ const makeCustomer = ( { amount: String(toReceive.value), denom: uusdcOnAgoric }, 'C4', ); - t.log(who, 'sees', ibcTransferMsg.token, 'sent to', EUD); + t.log(who, 'sees', ibcTransferMsg.token, 'sending to', EUD); if (!(EUD as string).startsWith('noble')) { t.like( JSON.parse(ibcTransferMsg.memo), @@ -579,6 +595,7 @@ test.serial('Contract skips advance when risks identified', async t => { test.serial('STORY01: advancing happy path for 100 USDC', async t => { const { common: { + bootstrap: { storage }, brands: { usdc }, commonPrivateArgs: { feeConfig }, utils: { inspectBankBridge, transmitTransferAck }, @@ -593,10 +610,15 @@ test.serial('STORY01: advancing happy path for 100 USDC', async t => { const bridgePos = snapshot(); const sent1 = await cust1.sendFast(t, 108_000_000n, 'osmo1234advanceHappy'); await transmitTransferAck(); // ack IBC transfer for advance - // Nothing we can check here, unless we want to inspect calls to `trace`. - // `test/exos/advancer.test.ts` covers calls to `log: LogFn` with mocks. - // This is still helpful to call, so we can observe "Advance transfer - // fulfilled" in the test output. + const expectedTransitions = [ + { evidence: sent1, status: 'OBSERVED' }, + { status: 'ADVANCING' }, + { status: 'ADVANCED' }, + ]; + t.deepEqual( + storage.getDeserialized(`fun.txns.${sent1.txHash}`), + expectedTransitions, + ); const { calculateAdvance, calculateSplit } = makeFeeTools(feeConfig); const expectedAdvance = calculateAdvance(usdc.make(sent1.tx.amount)); @@ -663,6 +685,11 @@ test.serial('STORY01: advancing happy path for 100 USDC', async t => { }, 'metrics after advancing', ); + + t.deepEqual(storage.getDeserialized(`fun.txns.${sent1.txHash}`), [ + ...expectedTransitions, + { split, status: 'DISBURSED' }, + ]); }); // most likely in exo unit tests @@ -781,7 +808,6 @@ test.serial('C20 - Contract MUST function with an empty pool', async t => { utils: { transmitTransferAck }, }, evm: { cctp, txPub }, - startKit: { metricsSub }, bridges: { snapshot, since }, mint, } = t.context; @@ -795,11 +821,6 @@ test.serial('C20 - Contract MUST function with an empty pool', async t => { await transmitTransferAck(); // ack IBC transfer for forward }); -// advancedEarly stuff -test.todo( - 'C12 - Contract MUST only pay back the Pool only if they started the advance before USDC is minted', -); - test.todo('C18 - forward - MUST log and alert these incidents'); test.serial('Settlement for unknown transaction (operator down)', async t => { @@ -808,10 +829,13 @@ test.serial('Settlement for unknown transaction (operator down)', async t => { bridges: { snapshot, since }, evm: { cctp, txPub }, common: { + bootstrap: { storage }, commonPrivateArgs: { feeConfig }, + mocks: { transferBridge }, utils: { transmitTransferAck }, }, mint, + addresses, } = t.context; const operators = await sync.ocw.promise; @@ -822,7 +846,9 @@ test.serial('Settlement for unknown transaction (operator down)', async t => { const opDown = makeCustomer('Otto', cctp, txPub.publisher, feeConfig); const bridgePos = snapshot(); - const sent = await opDown.sendFast(t, 20_000_000n, 'osmo12345'); + const EUD = 'osmo10tt0'; + const mintAmt = 5_000_000n; + const sent = await opDown.sendFast(t, mintAmt, EUD); await mint(sent); const bridgeTraffic = since(bridgePos); @@ -830,21 +856,112 @@ test.serial('Settlement for unknown transaction (operator down)', async t => { bridgeTraffic.bank, [ { - amount: '20000000', + amount: String(mintAmt), sender: 'faucet', type: 'VBANK_GRAB', }, { - amount: '20000000', + amount: String(mintAmt), recipient: 'agoric1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc09z0g', type: 'VBANK_GIVE', }, ], '20 USDC arrive at the settlement account', ); - t.deepEqual(bridgeTraffic.local, [], 'no IBC transfers'); await transmitTransferAck(); + t.deepEqual(bridgeTraffic.local, [], 'no IBC transfers'); + + // activate oracles and submit evidence; expect Settler to forward (slow path) + // 'C12 - Contract MUST only pay back the Pool (fees) only if they started the advance before USDC is minted', + operators[0].setActive(true); + operators[1].setActive(true); + // set the 3rd operator to inactive so it doesn't report a 2nd time + operators[2].setActive(false); + + // compute nonce from initial report so a new txId is not generated by `sendFast` helper + const nonce = Number(sent.txHash.slice(2)); + await opDown.sendFast(t, mintAmt, EUD, false, nonce); + + const [outgoingForward] = since(bridgePos).local; + t.like(outgoingForward, { + type: 'VLOCALCHAIN_EXECUTE_TX', + address: addresses.settlementAccount, + messages: [ + { + '@type': '/ibc.applications.transfer.v1.MsgTransfer', + }, + ], + }); + const [outgoingForwardMessage] = outgoingForward.messages; + t.is( + outgoingForwardMessage.token.amount, + String(sent.tx.amount), + 'full amount is transferred via `.forward()`', + ); + + const forwardInfo = JSON.parse(outgoingForwardMessage.memo).forward; + t.is(forwardInfo.receiver, EUD, 'receiver is osmo10tt0'); + + // in lieu of transmitTransferAck so we can set a nonce that matches our initial Advance + await E(transferBridge).fromBridge( + buildVTransferEvent({ + receiver: outgoingForwardMessage.receiver, + sender: outgoingForwardMessage.sender, + target: outgoingForwardMessage.sender, + sourceChannel: outgoingForwardMessage.sourceChannel, + sequence: BigInt(nonce), + denom: outgoingForwardMessage.token.denom, + amount: BigInt(outgoingForwardMessage.token.amount), + }), + ); + await eventLoopIteration(); + + t.deepEqual(storage.getDeserialized(`fun.txns.${sent.txHash}`), [ + { evidence: sent, status: 'OBSERVED' }, + { status: 'FORWARDED' }, + ]); +}); + +test.serial('mint received while ADVANCING', async t => { + // Settler should `disburse` on Transfer success + const { + bridges: { snapshot, since }, + common: { + commonPrivateArgs: { feeConfig }, + utils, + brands: { usdc }, + bootstrap: { storage }, + }, + evm: { cctp, txPub }, + mint, + startKit: { zoe, instance, metricsSub }, + } = t.context; + + t.log('top of liquidity pool'); + const usdcPurse = purseOf(usdc.issuer, utils); + const lp999 = makeLP('Leo ', usdcPurse(999_000_000n), zoe, instance); + await E(lp999).deposit(t, 999_000_000n); + + const earlySettle = makeCustomer('Earl E.', cctp, txPub.publisher, feeConfig); + const bridgePos = snapshot(); + + await earlySettle.checkPoolAvailable(t, 5_000_000n, metricsSub); + const sent = await earlySettle.sendFast(t, 5_000_000n, 'osmo1earl3'); + await eventLoopIteration(); + earlySettle.checkSent(t, since(bridgePos)); + + await mint(sent); + // mint received before Advance transfer settles + await utils.transmitTransferAck(); + + const split = makeFeeTools(feeConfig).calculateSplit(usdc.make(5_000_000n)); + t.deepEqual(storage.getDeserialized(`fun.txns.${sent.txHash}`), [ + { evidence: sent, status: 'OBSERVED' }, + { status: 'ADVANCING' }, + { status: 'ADVANCED' }, + { split, status: 'DISBURSED' }, + ]); }); test.todo( diff --git a/packages/fast-usdc/test/mocks.ts b/packages/fast-usdc/test/mocks.ts index d6196d3b7d55..88df11b65a4f 100644 --- a/packages/fast-usdc/test/mocks.ts +++ b/packages/fast-usdc/test/mocks.ts @@ -27,6 +27,7 @@ export const prepareMockOrchAccounts = ( ) => { // each can only be resolved/rejected once per test const poolAccountTransferVK = makeVowKit(); + const settleAccountTransferVK = makeVowKit(); const mockedPoolAccount = zone.exo('Mock Pool LocalOrchAccount', undefined, { transfer(destination: ChainAddress, amount: DenomAmount) { @@ -48,6 +49,7 @@ export const prepareMockOrchAccounts = ( const settlementAccountMock = zone.exo('Mock Settlement Account', undefined, { transfer(...args) { settlementCallLog.push(harden(['transfer', ...args])); + return settleAccountTransferVK.vow; }, }); const settlementAccount = settlementAccountMock as unknown as HostInterface< @@ -61,6 +63,7 @@ export const prepareMockOrchAccounts = ( settlement: { account: settlementAccount, callLog: settlementCallLog, + transferVResolver: settleAccountTransferVK.resolver, }, }; }; 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 52a2897ceb7b..abab8df6efde 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 @@ -594,7 +594,7 @@ Generated by [AVA](https://avajs.dev). 'Liquidity Pool_kindHandle': 'Alleged: kind', NobleAccount: 'Vow', 'Operator Kit_kindHandle': 'Alleged: kind', - PendingTxs: {}, + PendingSettleTxs: {}, PoolAccount: 'Vow', SeenTxs: [], SettleAccount: 'Vow', 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 2554e4fe33dc..077154035004 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 diff --git a/packages/fast-usdc/test/supports.ts b/packages/fast-usdc/test/supports.ts index 6a4608a0cf5d..6983f5a709d0 100644 --- a/packages/fast-usdc/test/supports.ts +++ b/packages/fast-usdc/test/supports.ts @@ -196,6 +196,8 @@ export const commonSetup = async (t: ExecutionContext) => { target: lastMsgTransfer.sender, sourceChannel: lastMsgTransfer.sourceChannel, sequence: ibcSequenceNonce, + denom: lastMsgTransfer.token.denom, + amount: BigInt(lastMsgTransfer.token.amount), }), ); // let the bridge handler finish diff --git a/scripts/gen-upgrade-proposal.sh b/scripts/gen-upgrade-proposal.sh index dab5a576e9a2..fdc802177de8 100755 --- a/scripts/gen-upgrade-proposal.sh +++ b/scripts/gen-upgrade-proposal.sh @@ -1,46 +1,295 @@ #! /bin/bash set -ueo pipefail +# shellcheck disable=SC2016 # intentionally escape `agd tx` +declare -r USAGE="Usage: $0"' \ + [