diff --git a/packages/builders/test/snapshots/orchestration-imports.test.js.md b/packages/builders/test/snapshots/orchestration-imports.test.js.md index b0a30362b20..4ef4fc7dc4e 100644 --- a/packages/builders/test/snapshots/orchestration-imports.test.js.md +++ b/packages/builders/test/snapshots/orchestration-imports.test.js.md @@ -524,6 +524,7 @@ Generated by [AVA](https://avajs.dev). }, }, }, + denomHash: Function denomHash {}, prepareChainHubAdmin: Function prepareChainHubAdmin {}, prepareCosmosInterchainService: Function prepareCosmosInterchainService {}, withOrchestration: Function withOrchestration {}, diff --git a/packages/builders/test/snapshots/orchestration-imports.test.js.snap b/packages/builders/test/snapshots/orchestration-imports.test.js.snap index d6c502b9743..32dc1cdcc42 100644 Binary files a/packages/builders/test/snapshots/orchestration-imports.test.js.snap and b/packages/builders/test/snapshots/orchestration-imports.test.js.snap differ diff --git a/packages/fast-usdc/package.json b/packages/fast-usdc/package.json index cd889223866..e887503bf07 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/constants.js b/packages/fast-usdc/src/constants.js new file mode 100644 index 00000000000..97fbd9c3f47 --- /dev/null +++ b/packages/fast-usdc/src/constants.js @@ -0,0 +1,27 @@ +/** + * Status values for FastUSDC. + * + * @enum {(typeof TxStatus)[keyof typeof TxStatus]} + */ +export const TxStatus = /** @type {const} */ ({ + /** tx was observed but not advanced */ + Observed: 'OBSERVED', + /** IBC transfer is initiated */ + Advanced: 'ADVANCED', + /** settlement for matching advance received and funds dispersed */ + Settled: 'SETTLED', +}); +harden(TxStatus); + +/** + * Status values for the StatusManager. + * + * @enum {(typeof PendingTxStatus)[keyof typeof PendingTxStatus]} + */ +export const PendingTxStatus = /** @type {const} */ ({ + /** tx was observed but not advanced */ + Observed: 'OBSERVED', + /** IBC transfer is initiated */ + Advanced: 'ADVANCED', +}); +harden(PendingTxStatus); diff --git a/packages/fast-usdc/src/exos/README.md b/packages/fast-usdc/src/exos/README.md new file mode 100644 index 00000000000..f65fb20a958 --- /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 28f146515f1..ab5443066d3 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 59356b1b795..4b33e635b29 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 ea1f69b5827..15c82f93a22 100644 --- a/packages/fast-usdc/src/exos/status-manager.js +++ b/packages/fast-usdc/src/exos/status-manager.js @@ -1,12 +1,175 @@ +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, SetStore} from '@agoric/store'; * @import {Zone} from '@agoric/zone'; + * @import {CctpTxEvidence, NobleAddress, SeenTxKey, 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); +}; /** + * Get the key for the seenTxs SetStore. + * + * The key is a composite of `NobleAddress` and transaction `amount` and not + * meant to be parsable. + * + * @param {CctpTxEvidence} evidence + * @returns {SeenTxKey} + */ +const seenTxKeyOf = evidence => { + const { txHash, chainId } = evidence; + return `seenTx:${JSON.stringify([txHash, chainId])}`; +}; + +/** + * 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), + }); + + /** @type {SetStore} */ + const seenTxs = zone.setStore('SeenTxs', { + keyShape: M.string(), + }); + + /** + * Ensures that `txHash+chainId` has not been processed + * and adds entry to `seenTxs` set. + * + * Also records the CctpTxEvidence and status in `pendingTxs`. + * + * @param {CctpTxEvidence} evidence + * @param {PendingTxStatus} status + */ + const recordPendingTx = (evidence, status) => { + const seenKey = seenTxKeyOf(evidence); + if (seenTxs.has(seenKey)) { + throw makeError(`Transaction already seen: ${q(seenKey)}`); + } + seenTxs.add(seenKey); + + 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()), + lookupPending: 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 pending entries for a given address and amount + * + * @param {NobleAddress} address + * @param {bigint} amount + * @returns {PendingTx[]} + */ + lookupPending(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 fb1197e4ae5..445073dcfb5 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/typeGuards.js b/packages/fast-usdc/src/typeGuards.js new file mode 100644 index 00000000000..2f10318fc57 --- /dev/null +++ b/packages/fast-usdc/src/typeGuards.js @@ -0,0 +1,39 @@ +import { M } from '@endo/patterns'; +import { PendingTxStatus } from './constants.js'; + +/** + * @import {TypedPattern} from '@agoric/internal'; + * @import {CctpTxEvidence, PendingTx} from './types.js'; + */ + +/** @type {TypedPattern} */ +export const EvmHashShape = M.string({ + stringLengthLimit: 66, +}); +harden(EvmHashShape); + +/** @type {TypedPattern} */ +export const CctpTxEvidenceShape = { + aux: { + forwardingChannel: M.string(), + recipientAddress: M.string(), + }, + blockHash: EvmHashShape, + blockNumber: M.bigint(), + blockTimestamp: M.bigint(), + chainId: M.number(), + tx: { + amount: M.bigint(), + forwardingAddress: M.string(), + }, + txHash: EvmHashShape, +}; +harden(CctpTxEvidenceShape); + +/** @type {TypedPattern} */ +// @ts-expect-error TypedPattern can't handle spreading? +export const PendingTxShape = { + ...CctpTxEvidenceShape, + status: M.or(...Object.values(PendingTxStatus)), +}; +harden(PendingTxShape); diff --git a/packages/fast-usdc/src/types-index.d.ts b/packages/fast-usdc/src/types-index.d.ts new file mode 100644 index 00000000000..06c33f562f4 --- /dev/null +++ b/packages/fast-usdc/src/types-index.d.ts @@ -0,0 +1 @@ +export type * from './types.js'; diff --git a/packages/fast-usdc/src/types-index.js b/packages/fast-usdc/src/types-index.js new file mode 100644 index 00000000000..cb0ff5c3b54 --- /dev/null +++ b/packages/fast-usdc/src/types-index.js @@ -0,0 +1 @@ +export {}; diff --git a/packages/fast-usdc/src/types.ts b/packages/fast-usdc/src/types.ts new file mode 100644 index 00000000000..ed6249f2ff4 --- /dev/null +++ b/packages/fast-usdc/src/types.ts @@ -0,0 +1,38 @@ +import type { ChainAddress } from '@agoric/orchestration'; +import type { IBCChannelID } from '@agoric/vats'; +import type { PendingTxStatus } from './constants.js'; + +export type EvmHash = `0x${string}`; +export type NobleAddress = `noble1${string}`; + +export interface CctpTxEvidence { + /** from Noble RPC */ + aux: { + forwardingChannel: IBCChannelID; + recipientAddress: ChainAddress['value']; + }; + blockHash: EvmHash; + blockNumber: bigint; + blockTimestamp: bigint; + chainId: number; + /** data covered by signature (aka txHash) */ + tx: { + amount: bigint; + forwardingAddress: NobleAddress; + }; + txHash: EvmHash; +} + +export type LogFn = (...args: unknown[]) => void; + +export interface PendingTx extends CctpTxEvidence { + status: PendingTxStatus; +} + +/** internal key for `StatusManager` exo */ +export type PendingTxKey = `pendingTx:${string}`; + +/** internal key for `StatusManager` exo */ +export type SeenTxKey = `seenTx:${string}`; + +export type * from './constants.js'; diff --git a/packages/fast-usdc/src/utils/address.js b/packages/fast-usdc/src/utils/address.js new file mode 100644 index 00000000000..f66b839f5ac --- /dev/null +++ b/packages/fast-usdc/src/utils/address.js @@ -0,0 +1,63 @@ +import { makeError, q } from '@endo/errors'; + +/** + * Very minimal 'URL query string'-like parser that handles: + * - Query string delimiter (?) + * - Key-value separator (=) + * - Query parameter separator (&) + * + * Does not handle: + * - Subpaths (`agoric1bech32addr/opt/account?k=v`) + * - URI encoding/decoding (`%20` -> ` `) + * - note: `decodeURIComponent` seems to be available in XS + * - Multiple question marks (foo?bar=1?baz=2) + * - Empty parameters (foo=) + * - Array parameters (`foo?k=v1&k=v2` -> k: [v1, v2]) + * - Parameters without values (foo&bar=2) + */ +export const addressTools = { + /** + * @param {string} address + * @returns {boolean} + */ + hasQueryParams: address => { + try { + const { params } = addressTools.getQueryParams(address); + return Object.keys(params).length > 0; + } catch { + return false; + } + }, + /** + * @param {string} address + * @returns {{ address: string, params: Record}} + */ + getQueryParams: address => { + const parts = address.split('?'); + if (parts.length === 0 || parts.length > 2) { + throw makeError( + `Invalid input. Must be of the form 'address?params': ${q(address)}`, + ); + } + const result = { + address: parts[0], + params: {}, + }; + + // no parameters, return early + if (parts.length === 1) { + return result; + } + + const paramPairs = parts[1].split('&'); + for (const pair of paramPairs) { + const [key, value] = pair.split('='); + if (!key || !value) { + throw makeError(`Invalid parameter format in pair: ${q(pair)}`); + } + result.params[key] = value; + } + + return result; + }, +}; diff --git a/packages/fast-usdc/test/constants.test.ts b/packages/fast-usdc/test/constants.test.ts new file mode 100644 index 00000000000..b2daefd0f9e --- /dev/null +++ b/packages/fast-usdc/test/constants.test.ts @@ -0,0 +1,12 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { TxStatus, PendingTxStatus } from '../src/constants.js'; + +const { values } = Object; + +test('PendingTxStatus is a subset of TxStatus', t => { + const txStatuses = values(TxStatus); + const difference = values(PendingTxStatus).filter( + status => !txStatuses.includes(status), + ); + t.deepEqual(difference, [], 'PendingTxStatus value(s) not in TxStatus'); +}); 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 00000000000..c285a46c6d4 --- /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.lookupPending( + 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.lookupPending( + 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.lookupPending( + 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 00000000000..e8ff695f0b1 --- /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.lookupPending( + 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.lookupPending( + 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 00000000000..ee892fee00d --- /dev/null +++ b/packages/fast-usdc/test/exos/status-manager.test.ts @@ -0,0 +1,180 @@ +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.lookupPending( + 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.lookupPending( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + + t.is(entries[0]?.status, PendingTxStatus.Observed); +}); + +test('cannot process same tx twice', t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + statusManager.advance(evidence); + + t.throws(() => statusManager.advance(evidence), { + message: + 'Transaction already seen: "seenTx:[\\"0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702\\",1]"', + }); + + t.throws(() => statusManager.observe(evidence), { + message: + 'Transaction already seen: "seenTx:[\\"0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702\\",1]"', + }); + + // new txHash should not throw + t.notThrows(() => statusManager.advance({ ...evidence, txHash: '0xtest2' })); + // new chainId with existing txHash should not throw + t.notThrows(() => statusManager.advance({ ...evidence, chainId: 9999 })); +}); + +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.lookupPending( + 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.lookupPending( + 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.lookupPending( + 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.lookupPending('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.lookupPending( + 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.lookupPending( + 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 new file mode 100644 index 00000000000..69363c988c7 --- /dev/null +++ b/packages/fast-usdc/test/fixtures.ts @@ -0,0 +1,118 @@ +import type { VTransferIBCEvent } from '@agoric/vats'; +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', + 'AGORIC_NO_PARAMS', +] as const; + +type MockScenario = (typeof mockScenarios)[number]; + +export const MockCctpTxEvidences: Record< + MockScenario, + (receiverAddress?: string) => CctpTxEvidence +> = { + AGORIC_PLUS_OSMO: (receiverAddress?: string) => ({ + blockHash: + '0x90d7343e04f8160892e94f02d6a9b9f255663ed0ac34caca98544c8143fee665', + blockNumber: 21037663n, + blockTimestamp: 1730762090n, + txHash: + '0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702', + tx: { + amount: 150000000n, + forwardingAddress: 'noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelqkd', + }, + aux: { + forwardingChannel: 'channel-21', + recipientAddress: + receiverAddress || + 'agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek?EUD=osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + }, + chainId: 1, + }), + AGORIC_PLUS_DYDX: (receiverAddress?: string) => ({ + blockHash: + '0x80d7343e04f8160892e94f02d6a9b9f255663ed0ac34caca98544c8143fee699', + blockNumber: 21037669n, + blockTimestamp: 1730762099n, + txHash: + '0xd81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761799', + tx: { + amount: 200000000n, + forwardingAddress: 'noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelktz', + }, + aux: { + forwardingChannel: 'channel-21', + recipientAddress: + receiverAddress || + 'agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek?EUD=dydx183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + }, + 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 = { + // (XXX confirm) FungibleTokenPacketData is from the perspective of the counterparty + denom: 'uusdc', + sourceChannel: + fetchedChainInfo.agoric.connections['noble-1'].transferChannel + .counterPartyChannelId, + destinationChannel: + fetchedChainInfo.agoric.connections['noble-1'].transferChannel.channelId, +}; + +export const MockVTransferEvents: Record< + MockScenario, + (receiverAddress?: string) => VTransferIBCEvent +> = { + AGORIC_PLUS_OSMO: (recieverAddress?: string) => + buildVTransferEvent({ + ...nobleDefaultVTransferParams, + amount: MockCctpTxEvidences.AGORIC_PLUS_OSMO().tx.amount, + sender: MockCctpTxEvidences.AGORIC_PLUS_OSMO().tx.forwardingAddress, + receiver: + recieverAddress || + MockCctpTxEvidences.AGORIC_PLUS_OSMO().aux.recipientAddress, + }), + AGORIC_PLUS_DYDX: (recieverAddress?: string) => + buildVTransferEvent({ + ...nobleDefaultVTransferParams, + amount: MockCctpTxEvidences.AGORIC_PLUS_DYDX().tx.amount, + sender: MockCctpTxEvidences.AGORIC_PLUS_DYDX().tx.forwardingAddress, + receiver: + 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 00000000000..ba2ba51d372 --- /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; diff --git a/packages/fast-usdc/test/typeGuards.test.ts b/packages/fast-usdc/test/typeGuards.test.ts new file mode 100644 index 00000000000..9920fd7ce34 --- /dev/null +++ b/packages/fast-usdc/test/typeGuards.test.ts @@ -0,0 +1,39 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { mustMatch } from '@endo/patterns'; +import { TxStatus, PendingTxStatus } from '../src/constants.js'; +import { CctpTxEvidenceShape, PendingTxShape } from '../src/typeGuards.js'; +import type { CctpTxEvidence } from '../src/types.js'; + +import { MockCctpTxEvidences } from './fixtures.js'; + +test('CctpTxEvidenceShape', t => { + const specimen: CctpTxEvidence = harden( + MockCctpTxEvidences.AGORIC_PLUS_OSMO(), + ); + + t.notThrows(() => mustMatch(specimen, CctpTxEvidenceShape)); +}); + +test('PendingTxShape', t => { + const specimen: CctpTxEvidence & { status: TxStatus } = harden({ + ...MockCctpTxEvidences.AGORIC_PLUS_OSMO(), + status: PendingTxStatus.Observed, + }); + + t.notThrows(() => mustMatch(specimen, PendingTxShape)); + + t.notThrows(() => + mustMatch( + harden({ ...specimen, status: PendingTxStatus.Advanced }), + PendingTxShape, + ), + ); + + t.throws(() => + mustMatch( + harden({ ...specimen, status: TxStatus.Settled }), + PendingTxShape, + ), + ); +}); diff --git a/packages/fast-usdc/test/utils/address.test.ts b/packages/fast-usdc/test/utils/address.test.ts new file mode 100644 index 00000000000..107cb640a67 --- /dev/null +++ b/packages/fast-usdc/test/utils/address.test.ts @@ -0,0 +1,78 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { addressTools } from '../../src/utils/address.js'; + +const FIXTURES = { + AGORIC_WITH_DYDX: + 'agoric1bech32addr?EUD=dydx183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + AGORIC_WITH_OSMO: + 'agoric1bech32addr?EUD=osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + AGORIC_WITH_MULTIPLE: + 'agoric1bech32addr?EUD=osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men&CID=dydx-mainnet-1', + AGORIC_NO_PARAMS: 'agoric1bech32addr', + INVALID_MULTIPLE_QUESTION: 'agoric1bech32addr?param1=value1?param2=value2', + INVALID_PARAM_FORMAT: 'agoric1bech32addr?invalidparam', +} as const; + +// hasQueryParams tests +test('hasQueryParams: returns true when address has parameters', t => { + t.true(addressTools.hasQueryParams(FIXTURES.AGORIC_WITH_DYDX)); + t.true(addressTools.hasQueryParams(FIXTURES.AGORIC_WITH_OSMO)); + t.true(addressTools.hasQueryParams(FIXTURES.AGORIC_WITH_MULTIPLE)); +}); + +test('hasQueryParams: returns false when address has no parameters', t => { + t.false(addressTools.hasQueryParams(FIXTURES.AGORIC_NO_PARAMS)); +}); + +test('hasQueryParams: returns false for invalid parameter formats', t => { + t.false(addressTools.hasQueryParams(FIXTURES.INVALID_MULTIPLE_QUESTION)); + t.false(addressTools.hasQueryParams(FIXTURES.INVALID_PARAM_FORMAT)); +}); + +// getQueryParams tests - positive cases +test('getQueryParams: correctly parses address with single EUD parameter', t => { + const result = addressTools.getQueryParams(FIXTURES.AGORIC_WITH_DYDX); + t.deepEqual(result, { + address: 'agoric1bech32addr', + params: { + EUD: 'dydx183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + }, + }); +}); + +test('getQueryParams: correctly parses address with multiple parameters', t => { + const result = addressTools.getQueryParams(FIXTURES.AGORIC_WITH_MULTIPLE); + t.deepEqual(result, { + address: 'agoric1bech32addr', + params: { + EUD: 'osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + CID: 'dydx-mainnet-1', + }, + }); +}); + +test('getQueryParams: correctly handles address with no parameters', t => { + const result = addressTools.getQueryParams(FIXTURES.AGORIC_NO_PARAMS); + t.deepEqual(result, { + address: 'agoric1bech32addr', + params: {}, + }); +}); + +// getQueryParams tests - negative cases +test('getQueryParams: throws error for multiple question marks', t => { + t.throws( + () => addressTools.getQueryParams(FIXTURES.INVALID_MULTIPLE_QUESTION), + { + message: + 'Invalid input. Must be of the form \'address?params\': "agoric1bech32addr?param1=value1?param2=value2"', + }, + ); +}); + +test('getQueryParams: throws error for invalid parameter format', t => { + t.throws(() => addressTools.getQueryParams(FIXTURES.INVALID_PARAM_FORMAT), { + message: 'Invalid parameter format in pair: "invalidparam"', + }); +}); diff --git a/packages/orchestration/index.js b/packages/orchestration/index.js index e124fcacb12..9440115c5e6 100644 --- a/packages/orchestration/index.js +++ b/packages/orchestration/index.js @@ -7,5 +7,6 @@ export * from './src/types-index.js'; // no named exports export * from './src/exos/cosmos-interchain-service.js'; export * from './src/exos/chain-hub-admin.js'; export * from './src/typeGuards.js'; +export * from './src/utils/denomHash.js'; export { withOrchestration } from './src/utils/start-helper.js'; diff --git a/packages/orchestration/src/exos/chain-hub.js b/packages/orchestration/src/exos/chain-hub.js index 6b86cfe881a..3cc0ff20fe4 100644 --- a/packages/orchestration/src/exos/chain-hub.js +++ b/packages/orchestration/src/exos/chain-hub.js @@ -4,7 +4,11 @@ import { M } from '@endo/patterns'; import { BrandShape } from '@agoric/ertp/src/typeGuards.js'; import { VowShape } from '@agoric/vow'; -import { CosmosChainInfoShape, IBCConnectionInfoShape } from '../typeGuards.js'; +import { + ChainAddressShape, + CosmosChainInfoShape, + IBCConnectionInfoShape, +} from '../typeGuards.js'; import { getBech32Prefix } from '../utils/address.js'; /** @@ -13,7 +17,7 @@ import { getBech32Prefix } from '../utils/address.js'; * @import {Zone} from '@agoric/zone'; * @import {CosmosAssetInfo, CosmosChainInfo, IBCConnectionInfo} from '../cosmos-api.js'; * @import {ChainInfo, KnownChains} from '../chain-info.js'; - * @import {Denom} from '../orchestration-api.js'; + * @import {ChainAddress, Denom} from '../orchestration-api.js'; * @import {Remote} from '@agoric/internal'; * @import {TypedPattern} from '@agoric/internal'; */ @@ -181,7 +185,7 @@ const ChainHubI = M.interface('ChainHub', { registerAsset: M.call(M.string(), DenomDetailShape).returns(), getAsset: M.call(M.string()).returns(M.or(DenomDetailShape, M.undefined())), getDenom: M.call(BrandShape).returns(M.or(M.string(), M.undefined())), - getChainInfoByAddress: M.call(M.string()).returns(CosmosChainInfoShape), + makeChainAddress: M.call(M.string()).returns(ChainAddressShape), }); /** @@ -440,15 +444,20 @@ export const makeChainHub = (zone, agoricNames, vowTools) => { }, /** * @param {string} address bech32 address - * @returns {CosmosChainInfo} + * @returns {ChainAddress} */ - getChainInfoByAddress(address) { + makeChainAddress(address) { const prefix = getBech32Prefix(address); if (!bech32PrefixToChainName.has(prefix)) { throw makeError(`Chain info not found for bech32Prefix ${q(prefix)}`); } const chainName = bech32PrefixToChainName.get(prefix); - return chainInfos.get(chainName); + const { chainId } = chainInfos.get(chainName); + return harden({ + chainId, + value: address, + encoding: /** @type {const} */ ('bech32'), + }); }, }); diff --git a/packages/orchestration/test/exos/chain-hub.test.ts b/packages/orchestration/test/exos/chain-hub.test.ts index 2f3185cb6a3..b4f4823be97 100644 --- a/packages/orchestration/test/exos/chain-hub.test.ts +++ b/packages/orchestration/test/exos/chain-hub.test.ts @@ -160,7 +160,7 @@ test('toward asset info in agoricNames (#9572)', async t => { } }); -test('getChainInfoByAddress', async t => { +test('makeChainAddress', async t => { const { chainHub, nameAdmin, vt } = setup(); // use fetched chain info await registerKnownChains(nameAdmin); @@ -170,24 +170,24 @@ test('getChainInfoByAddress', async t => { const MOCK_ICA_ADDRESS = 'osmo1ht7u569vpuryp6utadsydcne9ckeh2v8dkd38v5hptjl3u2ewppqc6kzgd'; - t.like(chainHub.getChainInfoByAddress(MOCK_ICA_ADDRESS), { + t.deepEqual(chainHub.makeChainAddress(MOCK_ICA_ADDRESS), { chainId: 'osmosis-1', - bech32Prefix: 'osmo', + value: MOCK_ICA_ADDRESS, + encoding: 'bech32', }); t.throws( - () => - chainHub.getChainInfoByAddress(MOCK_ICA_ADDRESS.replace('osmo1', 'foo1')), + () => chainHub.makeChainAddress(MOCK_ICA_ADDRESS.replace('osmo1', 'foo1')), { message: 'Chain info not found for bech32Prefix "foo"', }, ); - t.throws(() => chainHub.getChainInfoByAddress('notbech32'), { + t.throws(() => chainHub.makeChainAddress('notbech32'), { message: 'No separator character for "notbech32"', }); - t.throws(() => chainHub.getChainInfoByAddress('1notbech32'), { + t.throws(() => chainHub.makeChainAddress('1notbech32'), { message: 'Missing prefix for "1notbech32"', }); }); diff --git a/packages/orchestration/test/snapshots/exports.test.ts.md b/packages/orchestration/test/snapshots/exports.test.ts.md index 2b058658b22..56f8e20e5d6 100644 --- a/packages/orchestration/test/snapshots/exports.test.ts.md +++ b/packages/orchestration/test/snapshots/exports.test.ts.md @@ -32,6 +32,7 @@ Generated by [AVA](https://avajs.dev). 'TxBodyOptsShape', 'TypedJsonShape', 'chainFacadeMethods', + 'denomHash', 'prepareChainHubAdmin', 'prepareCosmosInterchainService', 'withOrchestration', diff --git a/packages/orchestration/test/snapshots/exports.test.ts.snap b/packages/orchestration/test/snapshots/exports.test.ts.snap index fab9e78c6b8..223154bd90b 100644 Binary files a/packages/orchestration/test/snapshots/exports.test.ts.snap and b/packages/orchestration/test/snapshots/exports.test.ts.snap differ