From 1d2679d4f5e643b8cda2206019fb0eac392399aa Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Tue, 5 Nov 2024 17:27:18 -0500 Subject: [PATCH] feat: `StatusManager` scaffold - shows the various `StatusManager` MapStore states updates (`OBSERVED`, `ADVANCED`) via `Advancer` and `Settler` stubs --- packages/fast-usdc/package.json | 1 + packages/fast-usdc/src/exos/README.md | 26 +++ packages/fast-usdc/src/exos/advancer.js | 118 ++++++++++- packages/fast-usdc/src/exos/settler.js | 87 ++++++++- packages/fast-usdc/src/exos/status-manager.js | 133 ++++++++++++- packages/fast-usdc/src/fast-usdc.contract.js | 17 +- packages/fast-usdc/src/types.ts | 2 + packages/fast-usdc/test/exos/advancer.test.ts | 184 ++++++++++++++++++ packages/fast-usdc/test/exos/settler.test.ts | 99 ++++++++++ .../test/exos/status-manager.test.ts | 157 +++++++++++++++ packages/fast-usdc/test/fixtures.ts | 34 +++- packages/fast-usdc/test/mocks.ts | 51 +++++ 12 files changed, 894 insertions(+), 15 deletions(-) create mode 100644 packages/fast-usdc/src/exos/README.md create mode 100644 packages/fast-usdc/test/exos/advancer.test.ts create mode 100644 packages/fast-usdc/test/exos/settler.test.ts create mode 100644 packages/fast-usdc/test/exos/status-manager.test.ts create mode 100644 packages/fast-usdc/test/mocks.ts diff --git a/packages/fast-usdc/package.json b/packages/fast-usdc/package.json index cd889223866e..e887503bf078 100644 --- a/packages/fast-usdc/package.json +++ b/packages/fast-usdc/package.json @@ -36,6 +36,7 @@ "@agoric/orchestration": "^0.1.0", "@agoric/store": "^0.9.2", "@agoric/vow": "^0.1.0", + "@endo/base64": "^1.0.8", "@endo/common": "^1.2.7", "@endo/errors": "^1.2.7", "@endo/eventual-send": "^1.2.7", diff --git a/packages/fast-usdc/src/exos/README.md b/packages/fast-usdc/src/exos/README.md new file mode 100644 index 000000000000..f65fb20a958f --- /dev/null +++ b/packages/fast-usdc/src/exos/README.md @@ -0,0 +1,26 @@ +## **StatusManager** state diagram, showing different transitions + + +### Contract state diagram + +*Transactions are qualified by the OCW and EventFeed before arriving to the Advancer.* + +```mermaid +stateDiagram-v2 + [*] --> Advanced: Advancer .advance() + Advanced --> Settled: Settler .settle() after fees + [*] --> Observed: Advancer .observed() + Observed --> Settled: Settler .settle() sans fees + Settled --> [*] +``` + +### Complete state diagram (starting from OCW) + +```mermaid +stateDiagram-v2 + Observed --> Qualified + Observed --> Unqualified + Qualified --> Advanced + Advanced --> Settled + Qualified --> Settled +``` diff --git a/packages/fast-usdc/src/exos/advancer.js b/packages/fast-usdc/src/exos/advancer.js index 28f146515f14..ab5443066d3a 100644 --- a/packages/fast-usdc/src/exos/advancer.js +++ b/packages/fast-usdc/src/exos/advancer.js @@ -1,19 +1,125 @@ +import { assertAllDefined } from '@agoric/internal'; +import { ChainAddressShape } from '@agoric/orchestration'; +import { VowShape } from '@agoric/vow'; +import { makeError, q } from '@endo/errors'; +import { E } from '@endo/far'; +import { M } from '@endo/patterns'; +import { CctpTxEvidenceShape } from '../typeGuards.js'; +import { addressTools } from '../utils/address.js'; + /** + * @import {HostInterface} from '@agoric/async-flow'; + * @import {ChainAddress, ChainHub, Denom, DenomAmount, OrchestrationAccount} from '@agoric/orchestration'; + * @import {VowTools} from '@agoric/vow'; * @import {Zone} from '@agoric/zone'; - * @import {TransactionFeed} from './transaction-feed.js'; + * @import {CctpTxEvidence, LogFn} from '../types.js'; * @import {StatusManager} from './status-manager.js'; + * @import {TransactionFeed} from './transaction-feed.js'; */ -import { assertAllDefined } from '@agoric/internal'; - /** * @param {Zone} zone * @param {object} caps + * @param {ChainHub} caps.chainHub * @param {TransactionFeed} caps.feed + * @param {LogFn} caps.log * @param {StatusManager} caps.statusManager + * @param {VowTools} caps.vowTools */ -export const prepareAdvancer = (zone, { feed, statusManager }) => { - assertAllDefined({ feed, statusManager }); - return zone.exo('Fast USDC Advancer', undefined, {}); +export const prepareAdvancer = ( + zone, + { chainHub, feed, log, statusManager, vowTools: { watch } }, +) => { + assertAllDefined({ feed, statusManager, watch }); + + const transferHandler = zone.exo( + 'Fast USDC Advance Transfer Handler', + M.interface('TransferHandlerI', { + // TODO confirm undefined, and not bigint (sequence) + onFulfilled: M.call(M.undefined(), { + amount: M.bigint(), + destination: ChainAddressShape, + }).returns(M.undefined()), + onRejected: M.call(M.error(), { + amount: M.bigint(), + destination: ChainAddressShape, + }).returns(M.undefined()), + }), + { + /** + * @param {undefined} result TODO confirm this is not a bigint (sequence) + * @param {{ destination: ChainAddress; amount: bigint; }} ctx + */ + onFulfilled(result, { destination, amount }) { + 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? + log('Advance transfer rejected', q(error).toString()); + }, + }, + ); + + return zone.exoClass( + 'Fast USDC Advancer', + M.interface('AdvancerI', { + handleTransactionEvent: M.call(CctpTxEvidenceShape).returns(VowShape), + }), + /** + * @param {{ + * localDenom: Denom; + * poolAccount: HostInterface>; + * }} config + */ + config => harden(config), + { + /** @param {CctpTxEvidence} evidence */ + handleTransactionEvent(evidence) { + // TODO EventFeed will perform input validation checks. + const { recipientAddress } = evidence.aux; + const { EUD } = addressTools.getQueryParams(recipientAddress).params; + if (!EUD) { + statusManager.observe(evidence); + throw makeError( + `recipientAddress does not contain EUD param: ${q(recipientAddress)}`, + ); + } + + // TODO #10391 this can throw, and should make a status update in the catch + const destination = chainHub.makeChainAddress(EUD); + + /** @type {DenomAmount} */ + const requestedAmount = harden({ + denom: this.state.localDenom, + value: BigInt(evidence.tx.amount), + }); + + // TODO #10391 ensure there's enough funds in poolAccount + + const transferV = E(this.state.poolAccount).transfer( + destination, + requestedAmount, + ); + + // mark as Advanced since `transferV` initiates the advance + statusManager.advance(evidence); + + return watch(transferV, transferHandler, { + destination, + amount: requestedAmount.value, + }); + }, + }, + { + stateShape: harden({ + localDenom: M.string(), + poolAccount: M.remotable(), + }), + }, + ); }; harden(prepareAdvancer); diff --git a/packages/fast-usdc/src/exos/settler.js b/packages/fast-usdc/src/exos/settler.js index 59356b1b7958..4b33e635b29f 100644 --- a/packages/fast-usdc/src/exos/settler.js +++ b/packages/fast-usdc/src/exos/settler.js @@ -1,10 +1,19 @@ +import { assertAllDefined } from '@agoric/internal'; +import { atob } from '@endo/base64'; +import { makeError, q } from '@endo/errors'; +import { M } from '@endo/patterns'; + +import { addressTools } from '../utils/address.js'; + /** + * @import {FungibleTokenPacketData} from '@agoric/cosmic-proto/ibc/applications/transfer/v2/packet.js'; + * @import {Denom} from '@agoric/orchestration'; + * @import {IBCChannelID, VTransferIBCEvent} from '@agoric/vats'; * @import {Zone} from '@agoric/zone'; + * @import {NobleAddress} from '../types.js'; * @import {StatusManager} from './status-manager.js'; */ -import { assertAllDefined } from '@agoric/internal'; - /** * @param {Zone} zone * @param {object} caps @@ -12,6 +21,78 @@ import { assertAllDefined } from '@agoric/internal'; */ export const prepareSettler = (zone, { statusManager }) => { assertAllDefined({ statusManager }); - return zone.exo('Fast USDC Settler', undefined, {}); + return zone.exoClass( + 'Fast USDC Settler', + M.interface('SettlerI', { + receiveUpcall: M.call(M.record()).returns(M.promise()), + }), + /** + * + * @param {{ + * sourceChannel: IBCChannelID; + * remoteDenom: Denom + * }} config + */ + config => harden(config), + { + /** @param {VTransferIBCEvent} event */ + async receiveUpcall(event) { + if (event.packet.source_channel !== this.state.sourceChannel) { + // TODO #10390 log all early returns + // only interested in packets from the issuing chain + return; + } + const tx = /** @type {FungibleTokenPacketData} */ ( + JSON.parse(atob(event.packet.data)) + ); + if (tx.denom !== this.state.remoteDenom) { + // only interested in uusdc + return; + } + + if (!addressTools.hasQueryParams(tx.receiver)) { + // only interested in receivers with query params + return; + } + + const { params } = addressTools.getQueryParams(tx.receiver); + // TODO - what's the schema address parameter schema for FUSDC? + if (!params?.EUD) { + // only interested in receivers with EUD parameter + return; + } + + // TODO discern between SETTLED and OBSERVED; each has different fees/destinations + const hasPendingSettlement = statusManager.hasPendingSettlement( + // given the sourceChannel check, we can be certain of this cast + /** @type {NobleAddress} */ (tx.sender), + BigInt(tx.amount), + ); + if (!hasPendingSettlement) { + // TODO FAILURE PATH -> put money in recovery account or .transfer to receiver + // TODO should we have an ORPHANED TxStatus for this? + throw makeError( + `🚨 No pending settlement found for ${q(tx.sender)} ${q(tx.amount)}`, + ); + } + + // TODO disperse funds + // ~1. fee to contractFeeAccount + // ~2. remainder in poolAccount + + // update status manager, marking tx `SETTLED` + statusManager.settle( + /** @type {NobleAddress} */ (tx.sender), + BigInt(tx.amount), + ); + }, + }, + { + stateShape: harden({ + sourceChannel: M.string(), + remoteDenom: M.string(), + }), + }, + ); }; harden(prepareSettler); diff --git a/packages/fast-usdc/src/exos/status-manager.js b/packages/fast-usdc/src/exos/status-manager.js index ea1f69b5827d..4b18fc75ee36 100644 --- a/packages/fast-usdc/src/exos/status-manager.js +++ b/packages/fast-usdc/src/exos/status-manager.js @@ -1,12 +1,143 @@ +import { M } from '@endo/patterns'; +import { makeError, q } from '@endo/errors'; + +import { appendToStoredArray } from '@agoric/store/src/stores/store-utils.js'; +import { CctpTxEvidenceShape, PendingTxShape } from '../typeGuards.js'; +import { PendingTxStatus } from '../constants.js'; + /** + * @import {MapStore} from '@agoric/store'; * @import {Zone} from '@agoric/zone'; + * @import {CctpTxEvidence, NobleAddress, PendingTxKey, PendingTx} from '../types.js'; + */ + +/** + * Create the key for the pendingTxs MapStore. + * + * The key is a composite of `txHash` and `chainId` and not meant to be + * parsable. + * + * @param {NobleAddress} addr + * @param {bigint} amount + * @returns {PendingTxKey} */ +const makePendingTxKey = (addr, amount) => + `pendingTx:${JSON.stringify([addr, String(amount)])}`; + +/** + * Get the key for the pendingTxs MapStore. + * + * @param {CctpTxEvidence} evidence + * @returns {PendingTxKey} + */ +const pendingTxKeyOf = evidence => { + const { amount, forwardingAddress } = evidence.tx; + return makePendingTxKey(forwardingAddress, amount); +}; /** + * The `StatusManager` keeps track of Pending and Seen Transactions + * via {@link PendingTxStatus} states, aiding in coordination between the `Advancer` + * and `Settler`. + * + * XXX consider separate facets for `Advancing` and `Settling` capabilities. + * * @param {Zone} zone */ export const prepareStatusManager = zone => { - return zone.exo('Fast USDC Status Manager', undefined, {}); + /** @type {MapStore} */ + const pendingTxs = zone.mapStore('PendingTxs', { + keyShape: M.string(), + valueShape: M.arrayOf(PendingTxShape), + }); + + /** + * @param {CctpTxEvidence} evidence + * @param {PendingTxStatus} status + */ + const recordPendingTx = (evidence, status) => { + appendToStoredArray( + pendingTxs, + pendingTxKeyOf(evidence), + harden({ ...evidence, status }), + ); + }; + + return zone.exo( + 'Fast USDC Status Manager', + M.interface('StatusManagerI', { + advance: M.call(CctpTxEvidenceShape).returns(M.undefined()), + observe: M.call(CctpTxEvidenceShape).returns(M.undefined()), + hasPendingSettlement: M.call(M.string(), M.bigint()).returns(M.boolean()), + settle: M.call(M.string(), M.bigint()).returns(M.undefined()), + lookup: M.call(M.string(), M.bigint()).returns(M.arrayOf(PendingTxShape)), + }), + { + /** + * Add a new transaction with ADVANCED status + * @param {CctpTxEvidence} evidence + */ + advance(evidence) { + recordPendingTx(evidence, PendingTxStatus.Advanced); + }, + + /** + * Add a new transaction with OBSERVED status + * @param {CctpTxEvidence} evidence + */ + observe(evidence) { + recordPendingTx(evidence, PendingTxStatus.Observed); + }, + + /** + * Find an `ADVANCED` or `OBSERVED` tx waiting to be `SETTLED` + * + * @param {NobleAddress} address + * @param {bigint} amount + * @returns {boolean} + */ + hasPendingSettlement(address, amount) { + const key = makePendingTxKey(address, amount); + const pending = pendingTxs.get(key); + return !!pending.length; + }, + + /** + * Mark an `ADVANCED` or `OBSERVED` transaction as `SETTLED` and remove it + * + * @param {NobleAddress} address + * @param {bigint} amount + */ + settle(address, amount) { + const key = makePendingTxKey(address, amount); + const pending = pendingTxs.get(key); + + if (!pending.length) { + throw makeError(`No unsettled entry for ${q(key)}`); + } + + const pendingCopy = [...pending]; + pendingCopy.shift(); + // TODO, vstorage update for `TxStatus.Settled` + pendingTxs.set(key, harden(pendingCopy)); + }, + + /** + * Lookup all entries for a given address and amount + * + * @param {NobleAddress} address + * @param {bigint} amount + * @returns {PendingTx[]} + */ + lookup(address, amount) { + const key = makePendingTxKey(address, amount); + if (!pendingTxs.has(key)) { + throw makeError(`Key ${q(key)} not yet observed`); + } + return pendingTxs.get(key); + }, + }, + ); }; harden(prepareStatusManager); diff --git a/packages/fast-usdc/src/fast-usdc.contract.js b/packages/fast-usdc/src/fast-usdc.contract.js index fb1197e4ae52..445073dcfb54 100644 --- a/packages/fast-usdc/src/fast-usdc.contract.js +++ b/packages/fast-usdc/src/fast-usdc.contract.js @@ -1,12 +1,14 @@ import { BrandShape } from '@agoric/ertp/src/typeGuards.js'; import { withOrchestration } from '@agoric/orchestration'; import { M } from '@endo/patterns'; -import { assertAllDefined } from '@agoric/internal'; +import { assertAllDefined, makeTracer } from '@agoric/internal'; import { prepareTransactionFeed } from './exos/transaction-feed.js'; import { prepareSettler } from './exos/settler.js'; import { prepareAdvancer } from './exos/advancer.js'; import { prepareStatusManager } from './exos/status-manager.js'; +const trace = makeTracer('FastUsdc'); + /** * @import {OrchestrationPowers, OrchestrationTools} from '@agoric/orchestration/src/utils/start-helper.js'; * @import {Zone} from '@agoric/zone'; @@ -43,9 +45,16 @@ export const contract = async (zcf, privateArgs, zone, tools) => { const statusManager = prepareStatusManager(zone); const feed = prepareTransactionFeed(zone); - const settler = prepareSettler(zone, { statusManager }); - const advancer = prepareAdvancer(zone, { feed, statusManager }); - assertAllDefined({ feed, settler, advancer, statusManager }); + const makeSettler = prepareSettler(zone, { statusManager }); + const { chainHub, vowTools } = tools; + const makeAdvancer = prepareAdvancer(zone, { + chainHub, + feed, + log: trace, + statusManager, + vowTools, + }); + assertAllDefined({ feed, makeAdvancer, makeSettler, statusManager }); const creatorFacet = zone.exo('Fast USDC Creator', undefined, {}); diff --git a/packages/fast-usdc/src/types.ts b/packages/fast-usdc/src/types.ts index 9770ace414e4..efcdeaa7f4f4 100644 --- a/packages/fast-usdc/src/types.ts +++ b/packages/fast-usdc/src/types.ts @@ -23,6 +23,8 @@ export interface CctpTxEvidence { txHash: EvmHash; } +export type LogFn = (...args: unknown[]) => void; + export interface PendingTx extends CctpTxEvidence { status: PendingTxStatus; } diff --git a/packages/fast-usdc/test/exos/advancer.test.ts b/packages/fast-usdc/test/exos/advancer.test.ts new file mode 100644 index 000000000000..06e8e6678b68 --- /dev/null +++ b/packages/fast-usdc/test/exos/advancer.test.ts @@ -0,0 +1,184 @@ +import type { TestFn } from 'ava'; +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { denomHash, type Denom } from '@agoric/orchestration'; +import fetchedChainInfo from '@agoric/orchestration/src/fetched-chain-info.js'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import type { Zone } from '@agoric/zone'; +import type { VowTools } from '@agoric/vow'; +import { prepareAdvancer } from '../../src/exos/advancer.js'; +import { prepareStatusManager } from '../../src/exos/status-manager.js'; +import { prepareTransactionFeed } from '../../src/exos/transaction-feed.js'; + +import { commonSetup } from '../supports.js'; +import { MockCctpTxEvidences } from '../fixtures.js'; +import { + makeTestLogger, + prepareMockOrchAccounts, + type TestLogger, +} from '../mocks.js'; +import { PendingTxStatus } from '../../src/constants.js'; + +const test = anyTest as TestFn<{ + localDenom: Denom; + makeAdvancer: ReturnType; + rootZone: Zone; + statusManager: ReturnType; + vowTools: VowTools; + inspectLogs: TestLogger['inspectLogs']; +}>; + +test.beforeEach(async t => { + const common = await commonSetup(t); + const { + bootstrap: { rootZone, vowTools }, + facadeServices: { chainHub }, + } = common; + + const { log, inspectLogs } = makeTestLogger(t.log); + + chainHub.registerChain('dydx', fetchedChainInfo.dydx); + chainHub.registerChain('osmosis', fetchedChainInfo.osmosis); + + const statusManager = prepareStatusManager( + rootZone.subZone('status-manager'), + ); + const feed = prepareTransactionFeed(rootZone.subZone('feed')); + const makeAdvancer = prepareAdvancer(rootZone.subZone('advancer'), { + chainHub, + feed, + statusManager, + vowTools, + log, + }); + const localDenom = `ibc/${denomHash({ + denom: 'uusdc', + channelId: + fetchedChainInfo.agoric.connections['noble-1'].transferChannel.channelId, + })}`; + + t.context = { + localDenom, + makeAdvancer, + rootZone, + statusManager, + vowTools, + inspectLogs, + }; +}); + +test('advancer updated status to ADVANCED', async t => { + const { + inspectLogs, + localDenom, + makeAdvancer, + statusManager, + rootZone, + vowTools, + } = t.context; + + const { poolAccount, poolAccountTransferVResolver } = prepareMockOrchAccounts( + rootZone.subZone('poolAcct'), + { vowTools, log: t.log }, + ); + + const advancer = makeAdvancer({ + poolAccount, + localDenom, + }); + t.truthy(advancer, 'advancer instantiates'); + + // simulate input from EventFeed + const mockCttpTxEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + advancer.handleTransactionEvent(mockCttpTxEvidence); + t.log('Simulate advance `.transfer()` vow fulfillment'); + poolAccountTransferVResolver.resolve(); + await eventLoopIteration(); // wait for StatusManager to receive update + const entries = statusManager.lookup( + mockCttpTxEvidence.tx.forwardingAddress, + mockCttpTxEvidence.tx.amount, + ); + t.deepEqual( + entries, + [{ ...mockCttpTxEvidence, status: PendingTxStatus.Advanced }], + 'tx status updated to ADVANCED', + ); + + t.deepEqual( + inspectLogs(0), + [ + 'Advance transfer fulfilled', + '{"amount":"[150000000n]","destination":{"chainId":"osmosis-1","encoding":"bech32","value":"osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men"},"result":"[undefined]"}', + ], + 'contract logs advance', + ); +}); + +test('advancer does not update status on failed transfer', async t => { + const { + inspectLogs, + localDenom, + makeAdvancer, + statusManager, + rootZone, + vowTools, + } = t.context; + + const { poolAccount, poolAccountTransferVResolver } = prepareMockOrchAccounts( + rootZone.subZone('poolAcct2'), + { vowTools, log: t.log }, + ); + + const advancer = makeAdvancer({ poolAccount, localDenom }); + t.truthy(advancer, 'advancer instantiates'); + + // simulate input from EventFeed + const mockCttpTxEvidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); + advancer.handleTransactionEvent(mockCttpTxEvidence); + t.log('Simulate advance `.transfer()` vow rejection'); + poolAccountTransferVResolver.reject(new Error('simulated error')); + await eventLoopIteration(); // wait for StatusManager to receive update + const entries = statusManager.lookup( + mockCttpTxEvidence.tx.forwardingAddress, + mockCttpTxEvidence.tx.amount, + ); + t.deepEqual( + entries, + [{ ...mockCttpTxEvidence, status: PendingTxStatus.Advanced }], + 'tx status is still Advanced even though advance failed', + ); + t.deepEqual(inspectLogs(0), [ + 'Advance transfer rejected', + '"[Error: simulated error]"', + ]); +}); + +test('advancer updated status to OBSERVED if pre-condition checks fail', async t => { + const { localDenom, makeAdvancer, statusManager, rootZone, vowTools } = + t.context; + + const { poolAccount } = prepareMockOrchAccounts( + rootZone.subZone('poolAcct2'), + { vowTools, log: t.log }, + ); + + const advancer = makeAdvancer({ poolAccount, localDenom }); + t.truthy(advancer, 'advancer instantiates'); + + // simulate input from EventFeed + const mockCttpTxEvidence = MockCctpTxEvidences.AGORIC_NO_PARAMS(); + t.throws(() => advancer.handleTransactionEvent(mockCttpTxEvidence), { + message: + 'recipientAddress does not contain EUD param: "agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek"', + }); + + const entries = statusManager.lookup( + mockCttpTxEvidence.tx.forwardingAddress, + mockCttpTxEvidence.tx.amount, + ); + t.deepEqual( + entries, + [{ ...mockCttpTxEvidence, status: PendingTxStatus.Observed }], + 'tx status is still OBSERVED', + ); +}); diff --git a/packages/fast-usdc/test/exos/settler.test.ts b/packages/fast-usdc/test/exos/settler.test.ts new file mode 100644 index 000000000000..6370ea1e4e66 --- /dev/null +++ b/packages/fast-usdc/test/exos/settler.test.ts @@ -0,0 +1,99 @@ +import type { TestFn } from 'ava'; +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import fetchedChainInfo from '@agoric/orchestration/src/fetched-chain-info.js'; +import type { IBCChannelID } from '@agoric/vats'; +import type { Denom } from '@agoric/orchestration'; +import { PendingTxStatus } from '../../src/constants.js'; +import { prepareStatusManager } from '../../src/exos/status-manager.js'; +import { prepareSettler } from '../../src/exos/settler.js'; + +import { provideDurableZone } from '../supports.js'; +import { MockCctpTxEvidences, MockVTransferEvents } from '../fixtures.js'; +import type { CctpTxEvidence } from '../../src/types.js'; + +const test = anyTest as TestFn<{ + makeSettler: ReturnType; + statusManager: ReturnType; + defaultSettlerParams: { + sourceChannel: IBCChannelID; + remoteDenom: Denom; + }; + simulateAdvance: (evidence?: CctpTxEvidence) => CctpTxEvidence; +}>; + +test.before(t => { + const zone = provideDurableZone('settler-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + const makeSettler = prepareSettler(zone.subZone('settler'), { + statusManager, + }); + + const defaultSettlerParams = harden({ + sourceChannel: + fetchedChainInfo.agoric.connections['noble-1'].transferChannel + .counterPartyChannelId, + remoteDenom: 'uusdc', + }); + + const simulateAdvance = (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); + + return cctpTxEvidence; + }; + + t.context = { + makeSettler, + statusManager, + defaultSettlerParams, + simulateAdvance, + }; +}); + +test('StatusManger gets `SETTLED` update in happy path', async t => { + const { makeSettler, statusManager, defaultSettlerParams, simulateAdvance } = + t.context; + const settler = makeSettler(defaultSettlerParams); + + const cctpTxEvidence = simulateAdvance(); + t.deepEqual( + statusManager.lookup( + cctpTxEvidence.tx.forwardingAddress, + cctpTxEvidence.tx.amount, + ), + [ + { + ...cctpTxEvidence, + status: PendingTxStatus.Advanced, + }, + ], + ); + + t.log('Simulate incoming IBC settlement'); + void settler.receiveUpcall(MockVTransferEvents.AGORIC_PLUS_OSMO()); + + t.log('TODO test funds settled in right places'); + // TODO, test settlement of funds + + t.deepEqual( + statusManager.lookup( + cctpTxEvidence.tx.forwardingAddress, + cctpTxEvidence.tx.amount, + ), + [], + 'SETTLED entry removed from StatusManger', + ); + // TODO, confirm vstorage write for TxStatus.SETTLED +}); + +test.todo("StatusManager does not receive update when we can't settle"); + +test.todo('settler disperses funds'); + +test.todo('Observed -> Settle flow'); diff --git a/packages/fast-usdc/test/exos/status-manager.test.ts b/packages/fast-usdc/test/exos/status-manager.test.ts new file mode 100644 index 000000000000..45638f9d771d --- /dev/null +++ b/packages/fast-usdc/test/exos/status-manager.test.ts @@ -0,0 +1,157 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { PendingTxStatus } from '../../src/constants.js'; +import { prepareStatusManager } from '../../src/exos/status-manager.js'; +import { provideDurableZone } from '../supports.js'; +import { MockCctpTxEvidences } from '../fixtures.js'; +import type { CctpTxEvidence } from '../../src/types.js'; + +test('advance creates new entry with ADVANCED status', t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + statusManager.advance(evidence); + + const entries = statusManager.lookup( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + + t.is(entries[0]?.status, PendingTxStatus.Advanced); +}); + +test('observe creates new entry with OBSERVED status', t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + statusManager.observe(evidence); + + const entries = statusManager.lookup( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + + t.is(entries[0]?.status, PendingTxStatus.Observed); +}); + +test('settle removes entries from PendingTxs', t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + statusManager.advance(evidence); + statusManager.observe({ ...evidence, txHash: '0xtest1' }); + + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount); + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount); + + const entries = statusManager.lookup( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + t.is(entries.length, 0, 'Settled entry should be deleted'); +}); + +test('cannot SETTLE without an ADVANCED or OBSERVED entry', t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + + t.throws( + () => + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount), + { + message: + 'key "pendingTx:[\\"noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelqkd\\",\\"150000000\\"]" not found in collection "PendingTxs"', + }, + ); +}); + +test('settle SETTLES first matched entry', t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + + // advance two + statusManager.advance(evidence); + statusManager.advance({ ...evidence, txHash: '0xtest2' }); + // also settles OBSERVED statuses + statusManager.observe({ ...evidence, txHash: '0xtest3' }); + + // settle will settle the first match + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount); + const entries0 = statusManager.lookup( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + t.is(entries0.length, 2); + // TODO, check vstorage for PendingTxStatus.Settled for 1st tx + t.is( + entries0?.[0].status, + PendingTxStatus.Advanced, + 'first settled entry deleted', + ); + t.is( + entries0?.[1].status, + PendingTxStatus.Observed, + 'order of remaining entries preserved', + ); + + // settle again wih same args settles 2nd advance + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount); + // settle again wih same args settles remaining observe + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount); + const entries1 = statusManager.lookup( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + // TODO, check vstorage for TxStatus.Settled + t.is(entries1?.length, 0, 'settled entries are deleted'); + + t.throws( + () => + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount), + { + message: + 'No unsettled entry for "pendingTx:[\\"noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelqkd\\",\\"150000000\\"]"', + }, + 'No more matches to settle', + ); +}); + +test('lookup throws when presented a key it has not seen', t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + t.throws(() => statusManager.lookup('noble123', 1n), { + message: 'Key "pendingTx:[\\"noble123\\",\\"1\\"]" not yet observed', + }); +}); + +test('StatusManagerKey logic handles addresses with hyphens', async t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + const evidence: CctpTxEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + evidence.tx.forwardingAddress = 'noble1-foo'; + + statusManager.advance(evidence); + + const entries = statusManager.lookup( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + + t.is(entries.length, 1); + t.is(entries[0]?.status, PendingTxStatus.Advanced); + + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount); + const remainingEntries = statusManager.lookup( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + t.is(remainingEntries.length, 0, 'Entry should be settled'); +}); diff --git a/packages/fast-usdc/test/fixtures.ts b/packages/fast-usdc/test/fixtures.ts index 138e137930f3..69363c988c75 100644 --- a/packages/fast-usdc/test/fixtures.ts +++ b/packages/fast-usdc/test/fixtures.ts @@ -3,7 +3,11 @@ import { buildVTransferEvent } from '@agoric/orchestration/tools/ibc-mocks.js'; import fetchedChainInfo from '@agoric/orchestration/src/fetched-chain-info.js'; import type { CctpTxEvidence } from '../src/types.js'; -const mockScenarios = ['AGORIC_PLUS_OSMO', 'AGORIC_PLUS_DYDX'] as const; +const mockScenarios = [ + 'AGORIC_PLUS_OSMO', + 'AGORIC_PLUS_DYDX', + 'AGORIC_NO_PARAMS', +] as const; type MockScenario = (typeof mockScenarios)[number]; @@ -49,6 +53,25 @@ export const MockCctpTxEvidences: Record< }, chainId: 1, }), + AGORIC_NO_PARAMS: (receiverAddress?: string) => ({ + blockHash: + '0x70d7343e04f8160892e94f02d6a9b9f255663ed0ac34caca98544c8143fee699', + blockNumber: 21037669n, + blockTimestamp: 1730762099n, + txHash: + '0xa81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761799', + tx: { + amount: 200000000n, + forwardingAddress: 'noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelyyy', + }, + aux: { + forwardingChannel: 'channel-21', + recipientAddress: + receiverAddress || + 'agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek', + }, + chainId: 1, + }), }; const nobleDefaultVTransferParams = { @@ -83,4 +106,13 @@ export const MockVTransferEvents: Record< recieverAddress || MockCctpTxEvidences.AGORIC_PLUS_DYDX().aux.recipientAddress, }), + AGORIC_NO_PARAMS: (recieverAddress?: string) => + buildVTransferEvent({ + ...nobleDefaultVTransferParams, + amount: MockCctpTxEvidences.AGORIC_NO_PARAMS().tx.amount, + sender: MockCctpTxEvidences.AGORIC_NO_PARAMS().tx.forwardingAddress, + receiver: + recieverAddress || + MockCctpTxEvidences.AGORIC_NO_PARAMS().aux.recipientAddress, + }), }; diff --git a/packages/fast-usdc/test/mocks.ts b/packages/fast-usdc/test/mocks.ts new file mode 100644 index 000000000000..ba2ba51d3721 --- /dev/null +++ b/packages/fast-usdc/test/mocks.ts @@ -0,0 +1,51 @@ +import type { + ChainAddress, + DenomAmount, + OrchestrationAccount, +} from '@agoric/orchestration'; +import type { Zone } from '@agoric/zone'; +import type { VowTools } from '@agoric/vow'; +import type { HostInterface } from '@agoric/async-flow'; +import type { LogFn } from '../src/types.js'; + +export const prepareMockOrchAccounts = ( + zone: Zone, + { + vowTools: { makeVowKit }, + log, + }: { vowTools: VowTools; log: (...args: any[]) => void }, +) => { + // can only be called once per test + const poolAccountTransferVK = makeVowKit(); + + const mockedPoolAccount = zone.exo('Pool LocalOrchAccount', undefined, { + transfer(destination: ChainAddress, amount: DenomAmount) { + log('PoolAccount.transfer() called with', destination, amount); + return poolAccountTransferVK.vow; + }, + }); + + const poolAccount = mockedPoolAccount as unknown as HostInterface< + OrchestrationAccount<{ + chainId: 'agoric'; + }> + >; + + return { + poolAccount, + poolAccountTransferVResolver: poolAccountTransferVK.resolver, + }; +}; + +export const makeTestLogger = (logger: LogFn) => { + const logs: unknown[][] = []; + const log = (...args: any[]) => { + logs.push(args); + logger(args); + }; + const inspectLogs = (index?: number) => + typeof index === 'number' ? logs[index] : logs; + return { log, inspectLogs }; +}; + +export type TestLogger = ReturnType;