diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000000..d8396b834f1 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,38 @@ +# Plan: Move portfolio vstorage schema into portfolio-api and add client helpers + +## Goals +- Make the vstorage schema (paths, shapes, and derived state rules) part of `portfolio-api` so external services can depend on it instead of pulling from `portfolio-contract`. +- Provide client-facing helpers in `portfolio-api` to read vstorage entries and infer coherent state even when data is split across paths or arrives in different orders. + +## Current findings +- Vstorage path builders and pattern guards live in `packages/portfolio-contract/src/type-guards.ts` (`makePortfolioPath/makePositionPath/makeFlowPath`, `PortfolioStatusShapeExt`, `PositionStatusShape`, `FlowStatusShape`, etc.) and are used directly by tests and downstream services (`services/ymax-planner/src/engine.ts` uses `PortfolioStatusShapeExt` and `flowIdFromKey` alongside separate vstorage queries). +- Flow lifecycle data is split: the contract writes `flowsRunning` on the portfolio node before emitting a `flows.flowN` node, so consumers infer an “init/planning” state by combining `flowsRunning` with the child paths; `engine.ts` currently does this by checking `flowsRunning` versus `flows.*` children. +- Client/test utilities duplicate path logic: `packages/portfolio-contract/tools/portfolio-actors.ts` builds position paths manually, and `multichain-testing/test/ymax0/ymax-deploy.test.ts` parses portfolio IDs from paths. +- No `portfolio-api` module currently surfaces the vstorage schema; it only defines TypeScript types and minimal guards (`src/type-guards.ts`). + +## Plan + +2) **Move existing schema references to the new module** + - Refactor `portfolio-contract` to import the schema helpers from `@agoric/portfolio-api` rather than defining them locally; breaking changes to `@aglocal/portfolio-contract/src/type-guards.ts` are acceptable (no compatibility shim needed). + - Update downstream consumers (planner `services/ymax-planner`, deploy/tests, and `tools/portfolio-actors.ts`) to import from `portfolio-api` instead of the contract package. + +3) **Add derived-state utilities for clients** + - Implement helper(s) in `portfolio-api` that read vstorage via `VstorageKit`/`chainStorageWatcher` style interfaces and return a normalized snapshot combining: + - Portfolio status (including `flowsRunning`) from the portfolio node. + - Flow nodes (`flows.flowN`, `flows.flowN.steps`, `flows.flowN.order`) when present. + - Derived flow states that mark flows as `init`/`planning` when present in `flowsRunning` but missing child nodes (covers the “flowsRunning written first” case). + - Provide functions to materialize positions using `positionKeys` and `positions.*` nodes, and utilities to join records by pool/chain for UIs and services. + - Scope (now): only the latest entry per vstorage node. Future work: optional helpers to walk historical entries for replay / richer test coverage. + +4) **Reduce duplicated path handling** + - Replace ad hoc path assembly/parsing in test utilities (`portfolio-actors`, `multichain-testing`, planner) with the new helpers to ensure consistent behavior and easier upgrades to path conventions. + - Document the expected topology of vstorage under `published..portfolios.*` and the event-vs-relational nature of updates (e.g., flow nodes accumulate history, portfolio node is current state). + +5) **Tests and documentation** + - Add unit tests in `portfolio-api` for the new schema/derived-state helpers using fixture capdata derived from existing contract snapshots. + - Update `packages/portfolio-api/README.md` to describe how to read portfolio data, detect in-progress flows, and interpret flow histories; include examples for “init” flows where `flowsRunning` has an entry but `flows.flowN` is not yet present. + +## Risks / open questions +- Avoid circular dependencies: confirm `portfolio-api` can import the necessary pattern utilities without pulling in contract code. +- Backward compatibility: not required for `@aglocal/*`; we can break `@aglocal/portfolio-contract/src/type-guards.ts` while moving schema into `portfolio-api`. +- Data retention: current scope is latest-per-node; follow-up work should add history/replay helpers for production vstorage stores and tests. diff --git a/multichain-testing/scripts/ymax-tool.ts b/multichain-testing/scripts/ymax-tool.ts index b94b32ae831..d6f6e6b9d28 100755 --- a/multichain-testing/scripts/ymax-tool.ts +++ b/multichain-testing/scripts/ymax-tool.ts @@ -12,7 +12,7 @@ import { type OfferArgsFor, type ProposalType, type TargetAllocation, -} from '@aglocal/portfolio-contract/src/type-guards.ts'; +} from '@agoric/portfolio-api'; import { makePortfolioSteps } from '@aglocal/portfolio-contract/tools/plan-transfers.ts'; import { makePortfolioQuery } from '@aglocal/portfolio-contract/tools/portfolio-actors.ts'; import { diff --git a/multichain-testing/test/ymax0/ymax-deploy.test.ts b/multichain-testing/test/ymax0/ymax-deploy.test.ts index 1c3de9221f8..ac2d62313e5 100644 --- a/multichain-testing/test/ymax0/ymax-deploy.test.ts +++ b/multichain-testing/test/ymax0/ymax-deploy.test.ts @@ -16,12 +16,9 @@ import { } from '@agoric/orchestration'; import type { TestFn } from 'ava'; import { - makeFlowPath, - makePositionPath, - portfolioIdOfPath, PortfolioStatusShapeExt, - type StatusFor, -} from '@aglocal/portfolio-contract/src/type-guards.ts'; + readPortfolioLatest, +} from '@agoric/portfolio-api'; import { mustMatch } from '@agoric/internal'; const { fromEntries, keys, values } = Object; @@ -240,8 +237,6 @@ async function* readHistory( } while (blockHeight > 0); } -const range = (n: number) => [...Array(n).keys()]; - // TODO: send an offer before running this test test('portfolio-opened', async t => { // t.timeout(1_000); @@ -262,28 +257,34 @@ test('portfolio-opened', async t => { const chopPub = (key: string) => key.replace(/^published./, ''); for (const portfolioKey of portfolioKeys) { - const portfolioId = portfolioIdOfPath(portfolioKey); - const portfolioInfo = (await vsc.readPublished( - chopPub(portfolioKey), - )) as StatusFor['portfolio']; + const segments = portfolioKey.split('.'); + const portfolioSegment = segments.pop(); + portfolioSegment || t.fail('missing portfolio path segment'); + const portfoliosPathPrefix = segments.join('.'); + const readLatest = (path: string) => vsc.readPublished(chopPub(path)); + const snapshot = await readPortfolioLatest({ + readLatest, + listChildren: path => vs.keys(path), + portfoliosPathPrefix, + portfolioKey: /** @type {`portfolio${number}`} */ (portfolioSegment), + includePositions: true, + }); + const portfolioInfo = snapshot.status; t.log(portfolioKey, portfolioInfo); - const { positionKeys, flowCount } = portfolioInfo; - for (const poolKey of positionKeys) { - // XXX this makePositionPath API is kinda messy - const positionKey = [ - 'ymax0', - 'portfolios', - ...makePositionPath(portfolioId, poolKey), - ].join('.'); - const positionInfo = (await vsc.readPublished( - positionKey, - )) as StatusFor['position']; - t.log(positionKey, positionInfo); + mustMatch(portfolioInfo, PortfolioStatusShapeExt, portfolioKey); + + const baseLogPath = chopPub(`${portfoliosPathPrefix}.${snapshot.portfolioKey}`); + for (const [poolKey, position] of Object.entries(snapshot.positions || {})) { + t.log( + `${baseLogPath}.positions.${poolKey}`, + position?.status ?? 'missing position node', + ); } - for (const flowNum of range(flowCount).map(x => x + 1)) { - const flowKey = makeFlowPath(portfolioId, flowNum).join('.'); + const flowsWithNodes = values(snapshot.flows).filter(flow => flow.status); + for (const flowNode of flowsWithNodes) { + const flowPath = `${portfoliosPathPrefix}.${snapshot.portfolioKey}.flows.${flowNode.flowKey}`; try { - for await (const { blockHeight, values } of readHistory(flowKey, { + for await (const { blockHeight, values } of readHistory(flowPath, { vstorage: vs, setTimeout, clearTimeout, @@ -291,7 +292,7 @@ test('portfolio-opened', async t => { try { const [jsonCapData] = values as string[]; const info = vsc.marshaller.fromCapData(JSON.parse(jsonCapData)); - t.log(flowKey, blockHeight, info); + t.log(flowPath, blockHeight, info); } catch (err) { t.log(blockHeight, values, err); } diff --git a/packages/agoric-cli/src/bin-agops.js b/packages/agoric-cli/src/bin-agops.js index 849eee327c3..a6829bbff72 100755 --- a/packages/agoric-cli/src/bin-agops.js +++ b/packages/agoric-cli/src/bin-agops.js @@ -15,6 +15,7 @@ import { Command, CommanderError, createCommand } from 'commander'; import { makeOracleCommand } from './commands/oracle.js'; import { makeGovCommand } from './commands/gov.js'; import { makePsmCommand } from './commands/psm.js'; +import { makePortfolioCommand } from './commands/portfolio.js'; import { makeReserveCommand } from './commands/reserve.js'; import { makeVaultsCommand } from './commands/vaults.js'; import { makePerfCommand } from './commands/perf.js'; @@ -65,6 +66,7 @@ const procIO = { }; program.addCommand(makeOracleCommand(procIO, logger)); +program.addCommand(makePortfolioCommand(procIO)); program.addCommand(makeReserveCommand(logger, procIO)); program.addCommand(makeTestCommand(procIO, { fetch })); diff --git a/packages/agoric-cli/src/commands/portfolio.js b/packages/agoric-cli/src/commands/portfolio.js new file mode 100644 index 00000000000..062d2e27b36 --- /dev/null +++ b/packages/agoric-cli/src/commands/portfolio.js @@ -0,0 +1,155 @@ +// @ts-check +/* eslint-env node */ +import { fetchEnvNetworkConfig, makeVstorageKit } from '@agoric/client-utils'; +import { readPortfolioHistoryEntries } from '@agoric/portfolio-api'; +import { Command } from 'commander'; + +const networkConfig = await fetchEnvNetworkConfig({ env: process.env, fetch }); + +const decodeCapDataValue = marshaller => value => { + if ( + value && + typeof value === 'object' && + 'body' in value && + 'slots' in value + ) { + return marshaller.fromCapData(value); + } + if (typeof value === 'string') { + return marshaller.fromCapData(JSON.parse(value)); + } + return value; +}; + +const formatFlowsRunning = flowsRunning => { + const entries = Object.entries(flowsRunning || {}); + if (!entries.length) return 'none'; + return entries + .map(([key, detail]) => + detail && 'type' in detail ? `${key}:${detail.type}` : key, + ) + .join(', '); +}; + +const describePortfolioStatus = status => + `policy=${status.policyVersion} rebalance=${status.rebalanceCount} positionKeys=${status.positionKeys.length} flowsRunning=${formatFlowsRunning(status.flowsRunning)}`; + +const toBigIntValue = amount => { + if (!amount || typeof amount !== 'object') return undefined; + const raw = amount.value; + if (typeof raw === 'bigint') return raw; + if (typeof raw === 'number') return BigInt(raw); + if (typeof raw === 'string') { + try { + return BigInt(raw); + } catch { + return undefined; + } + } + return undefined; +}; + +const describeFlowEvent = (entry, costBasis) => { + const { value } = entry; + const detailType = value.type || entry.detail?.type; + const amountValue = + toBigIntValue(value.amount || entry.detail?.amount) ?? 0n; + const { state } = value; + const step = 'step' in value ? value.step : undefined; + const how = 'how' in value ? value.how : undefined; + const parts = [ + `state=${state}`, + detailType ? `type=${detailType}` : undefined, + step !== undefined ? `step=${step}` : undefined, + how ? `how=${how}` : undefined, + amountValue ? `amount=${amountValue}` : undefined, + ].filter(Boolean); + let delta = 0n; + if (amountValue) { + if (detailType === 'deposit') delta = amountValue; + else if (detailType === 'withdraw') delta = -amountValue; + } + const nextBasis = costBasis + delta; + return { summary: parts.join(' '), delta, nextBasis }; +}; + +const describePositionStatus = (poolKey, value) => { + const totalIn = toBigIntValue(value.totalIn) ?? 0n; + const totalOut = toBigIntValue(value.totalOut) ?? 0n; + const net = totalIn - totalOut; + return { summary: `position=${poolKey} totalIn=${totalIn} totalOut=${totalOut} net=${net}`, totals: { totalIn, totalOut } }; +}; + +export const makePortfolioCommand = (io = {}) => { + const { stdout = process.stdout } = io; + const portfolio = new Command('portfolio').description( + 'Portfolio contract utilities', + ); + + portfolio + .command('history') + .description('Print portfolio + flow vstorage history') + .option('--instance ', 'contract instance name (default ymax1)', 'ymax1') + .requiredOption('--id ', 'portfolio numeric ID', Number) + .option('--limit ', 'limit entries (oldest first)', Number) + .option('--desc', 'show newest first') + .action(async ({ instance, id, limit, desc }) => { + const vsk = makeVstorageKit({ fetch }, networkConfig); + const decodeValue = decodeCapDataValue(vsk.marshaller); + const history = await readPortfolioHistoryEntries({ + readAt: vsk.vstorage.readAt, + listChildren: vsk.vstorage.keys, + portfoliosPathPrefix: `published.${instance}.portfolios`, + portfolioKey: `portfolio${id}`, + decodeValue, + sort: desc ? 'desc' : 'asc', + }); + const entries = limit ? history.slice(0, Number(limit)) : history; + if (!entries.length) { + stdout.write('No history available\n'); + return; + } + let costBasis = 0n; + const positionTotals = new Map(); + for (const entry of entries) { + let header; + let summaryLines = []; + if (entry.kind === 'portfolio') { + header = 'portfolio'; + summaryLines = [describePortfolioStatus(entry.value)]; + } else if (entry.kind === 'position') { + header = `position ${entry.poolKey}`; + const { summary, totals } = describePositionStatus( + entry.poolKey, + entry.value, + ); + const prev = positionTotals.get(entry.poolKey) || { + totalIn: 0n, + totalOut: 0n, + }; + const deltaIn = totals.totalIn - prev.totalIn; + const deltaOut = totals.totalOut - prev.totalOut; + const delta = deltaIn - deltaOut; + costBasis += delta; + positionTotals.set(entry.poolKey, totals); + summaryLines = [ + summary, + `Δin=${deltaIn} Δout=${deltaOut} costBasis=${costBasis}`, + ]; + } else { + header = `flow ${entry.flowKey}`; + const { summary, nextBasis } = describeFlowEvent(entry, costBasis); + costBasis = nextBasis; + summaryLines = [summary, `costBasis=${costBasis}`]; + } + stdout.write( + `\n[block ${entry.blockHeight}] ${header}\n ${summaryLines.join('\n ')}\n`, + ); + } + stdout.write( + `\nDisplayed ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} (of ${history.length})\n`, + ); + }); + + return portfolio; +}; diff --git a/packages/client-utils/src/types.ts b/packages/client-utils/src/types.ts index 6c82f774b75..4dfc76bc6cd 100644 --- a/packages/client-utils/src/types.ts +++ b/packages/client-utils/src/types.ts @@ -18,7 +18,7 @@ import type { UpdateRecord, } from '@agoric/smart-wallet/src/smartWallet.js'; import type { AssetInfo } from '@agoric/vats/src/vat-bank.js'; -import type { StatusFor } from '@aglocal/portfolio-contract/src/type-guards.js'; +import type { StatusFor } from '@agoric/portfolio-api'; import type { Installation, Instance, diff --git a/packages/portfolio-api/README.md b/packages/portfolio-api/README.md index 11e6d9637b2..a9ee9e7dc05 100644 --- a/packages/portfolio-api/README.md +++ b/packages/portfolio-api/README.md @@ -1,3 +1,59 @@ # Portfolio management API API shared between on-chain contract and external clients + +## Vstorage schema and readers + +Portfolio vstorage paths, shapes, and helpers now live here (see `src/vstorage-schema.ts`): + +- Path builders and parsers: `makePortfolioPath`, `makePositionPath`, `makeFlowPath`, `portfolioIdFromKey`, etc. +- Shape guards: `PortfolioStatusShapeExt`, `PositionStatusShape`, `FlowStatusShape`, `FlowStepsShape`. +- Pool mapping: `PoolPlaces` / `BeefyPoolPlaces` and allocation shapes. +- Derived reader: `readPortfolioLatest({ readLatest, listChildren, portfoliosPathPrefix, portfolioKey })` returns the latest portfolio state plus a joined view of `flowsRunning` and any `flows.flowN` nodes. It marks flows as `phase: 'init'` when they appear in `flowsRunning` but no flow node exists yet (covers the “flowsRunning written first” pattern). Pass `includePositions: true` to also fetch `positions.*` nodes alongside `positionKeys`, returning `positions` keyed by pool and `positionsByChain` keyed by chain. Currently this reads only the latest entry per node; history/replay helpers can be added later. +- Flow selectors: `selectPendingFlows(snapshot)` pulls the subset of `FlowNodeLatest` entries that still lack a `.flows.flowN` status node (phase `init`), making it easy for planners to find work. +- History iterator: `iterateVstorageHistory({ readAt, path, minHeight, decodeValue })` wraps `vstorage.readAt` into an async generator so tests and services can replay state transitions with consistent culling. +- History collector: `readPortfolioHistoryEntries({ readAt, listChildren, portfoliosPathPrefix, portfolioKey, decodeValue, sort })` emits ordered `portfolio`/`flow` events with decoded values. +- Mock readers: `makeMockVstorageReaders(initialEntries)` provides `readLatest`, `listChildren`, and `writeLatest` helpers backed by a Map for deterministic tests that exercise these utilities without hitting a chain follower. +- Materializer: `materializePortfolioPositions({ status, positionNodes, poolPlaces })` turns raw `positions.*` entries (or mock data) into the same `positions`/`positionsByChain` view for UIs that already have the node data. +- Place enumerator: `enumeratePortfolioPlaces({ status, poolPlaces })` returns normalized arrays of chain accounts (e.g., `@noble`) and position entries (e.g., `Aave_Base`) so planners can build balance queries without duplicating `accountIdByChain`/`PoolPlaces` joins. + +Example (latest-only): + +```js +import { readPortfolioLatest } from '@agoric/portfolio-api'; + +const snapshot = await readPortfolioLatest({ + readLatest: vstorage.readLatest, + listChildren: vstorage.keys, + portfoliosPathPrefix: 'published.ymax0.portfolios', + portfolioKey: 'portfolio3', + includePositions: true, +}); + +console.log(snapshot.flows.flow2.phase); // 'init' until flow node is written +console.log(snapshot.positions.USDN.place.chainName); // 'noble' +console.log(snapshot.positionsByChain.Base.positions.length); // grouped by chain + +const pendingFlows = selectPendingFlows(snapshot); +for (const flow of pendingFlows) { + console.log('needs activation', flow.flowKey, flow.detail); +} + +for await (const { blockHeight, values } of iterateVstorageHistory({ + readAt: vstorage.readAt, + path: `${portfoliosPathPrefix}.${snapshot.portfolioKey}.flows.flow1`, + minHeight: 5n, + decodeValue: json => vstorage.marshaller.fromCapData(JSON.parse(json as string)), +})) { + console.log('flow history entry', blockHeight, values.at(0)); +} + +const history = await readPortfolioHistoryEntries({ + readAt: vstorage.readAt, + listChildren: vstorage.keys, + portfoliosPathPrefix, + portfolioKey: 'portfolio3', + decodeValue: json => vstorage.marshaller.fromCapData(JSON.parse(json as string)), +}); +console.log(`history entries: ${history.length}`); +``` diff --git a/packages/portfolio-api/package.json b/packages/portfolio-api/package.json index 8dece437e07..dbc5d65d841 100644 --- a/packages/portfolio-api/package.json +++ b/packages/portfolio-api/package.json @@ -22,11 +22,15 @@ "lint:types": "yarn run -T tsc" }, "dependencies": { + "@agoric/ertp": "workspace:*", "@agoric/orchestration": "workspace:*", - "@endo/common": "^1.2.13" + "@endo/common": "^1.2.13", + "@endo/errors": "^1.2.13", + "@endo/patterns": "^1.7.0" }, "devDependencies": { "@agoric/internal": "workspace:*", + "@endo/init": "^1.1.12", "ava": "^5.3.0", "c8": "^10.1.3", "ts-blank-space": "^0.6.2", diff --git a/packages/portfolio-api/src/main.js b/packages/portfolio-api/src/main.js index 4c1caf2fd45..c6831628367 100644 --- a/packages/portfolio-api/src/main.js +++ b/packages/portfolio-api/src/main.js @@ -1,6 +1,7 @@ export * from './constants.js'; export * from './instruments.js'; export * from './resolver.js'; +export * from './vstorage-schema.js'; // eslint-disable-next-line import/export -- just types export * from './types-index.js'; diff --git a/packages/portfolio-api/src/types-index.d.ts b/packages/portfolio-api/src/types-index.d.ts index 06c33f562f4..666139a3840 100644 --- a/packages/portfolio-api/src/types-index.d.ts +++ b/packages/portfolio-api/src/types-index.d.ts @@ -1 +1,2 @@ export type * from './types.js'; +export type * from './vstorage-schema.js'; diff --git a/packages/portfolio-api/src/vstorage-schema.js b/packages/portfolio-api/src/vstorage-schema.js new file mode 100644 index 00000000000..961de3def20 --- /dev/null +++ b/packages/portfolio-api/src/vstorage-schema.js @@ -0,0 +1,852 @@ +/* eslint-disable jsdoc/require-param */ +/** + * Vstorage schema (paths, shapes, and helpers) for portfolio data. + * + * This module exists in `portfolio-api` so that services outside this repo can + * validate and interpret portfolio vstorage entries without depending on the + * contract package. + */ +// @ts-check + +import { AnyNatAmountShape } from '@agoric/orchestration'; +import { Fail } from '@endo/errors'; +import { M } from '@endo/patterns'; +import { YieldProtocol } from './constants.js'; + +/** + * @import {NatValue} from '@agoric/ertp'; + * @import {Brand} from '@agoric/ertp'; + * @import {Pattern} from '@endo/patterns'; + * @import {AccountId} from '@agoric/orchestration'; + * @import {InstrumentId} from './instruments.js'; + * @import {AxelarChain, SupportedChain} from './constants.js' + * @import {FlowDetail} from './types.js'; + * @import {FlowStep} from './types.js'; + * @import {PortfolioKey} from './types.js'; + * @import {PoolKey} from './types.js'; + * @import {AssetPlaceRef} from './types.js'; + * @import {StatusFor} from './types.js'; + */ + +const { keys, values, entries } = Object; + +/** + * @param {Brand<'nat'>} brand must be a 'nat' brand, not checked + * @param {NatValue} [min] + */ +export const makeNatAmountShape = (brand, min) => + harden({ brand, value: min ? M.gte(min) : M.nat() }); + +/** @typedef {{ protocol: 'USDN'; vault: null | 1; chainName: 'noble' } | { protocol: keyof typeof YieldProtocol; chainName: AxelarChain }} PoolPlaceInfo */ + +// XXX special handling. What's the functional difference from other places? +export const BeefyPoolPlaces = { + Beefy_re7_Avalanche: { + protocol: 'Beefy', + chainName: 'Avalanche', + }, + Beefy_morphoGauntletUsdc_Ethereum: { + protocol: 'Beefy', + chainName: 'Ethereum', + }, + Beefy_morphoSmokehouseUsdc_Ethereum: { + protocol: 'Beefy', + chainName: 'Ethereum', + }, + Beefy_morphoSeamlessUsdc_Base: { + protocol: 'Beefy', + chainName: 'Base', + }, + Beefy_compoundUsdc_Optimism: { + protocol: 'Beefy', + chainName: 'Optimism', + }, + Beefy_compoundUsdc_Arbitrum: { + protocol: 'Beefy', + chainName: 'Arbitrum', + }, +}; + +export const PoolPlaces = { + USDN: { protocol: 'USDN', vault: null, chainName: 'noble' }, // MsgSwap only + USDNVault: { protocol: 'USDN', vault: 1, chainName: 'noble' }, // MsgSwap, MsgLock + Aave_Avalanche: { protocol: 'Aave', chainName: 'Avalanche' }, + Aave_Ethereum: { protocol: 'Aave', chainName: 'Ethereum' }, + Aave_Optimism: { protocol: 'Aave', chainName: 'Optimism' }, + Aave_Arbitrum: { protocol: 'Aave', chainName: 'Arbitrum' }, + Aave_Base: { protocol: 'Aave', chainName: 'Base' }, + Compound_Ethereum: { protocol: 'Compound', chainName: 'Ethereum' }, + Compound_Optimism: { protocol: 'Compound', chainName: 'Optimism' }, + Compound_Arbitrum: { protocol: 'Compound', chainName: 'Arbitrum' }, + Compound_Base: { protocol: 'Compound', chainName: 'Base' }, + ...BeefyPoolPlaces, +}; +harden(PoolPlaces); + +/** + * Names of places where a portfolio may have a position. + * @typedef {InstrumentId} PoolKey + */ + +/** Ext for Extensible: includes PoolKeys in future upgrades */ +/** @typedef {string} PoolKeyExt */ + +/** Ext for Extensible: includes PoolKeys in future upgrades */ +export const PoolKeyShapeExt = M.string(); + +export const TargetAllocationShape = M.recordOf( + M.or(...keys(PoolPlaces)), + M.nat(), +); + +/** @type {Pattern} */ +export const TargetAllocationShapeExt = M.recordOf(PoolKeyShapeExt, M.nat()); + +// #region vstorage keys and values + +/** + * Creates vstorage path for portfolio status under published..portfolios. + * + * Portfolio status includes position counts, account mappings, and flow history. + * + * @param {number} id - Portfolio ID number + * @returns {readonly [`portfolio${number}`]} + */ +export const makePortfolioPath = id => [`portfolio${id}`]; + +const parseSuffixNumber = (prefix, key) => { + const match = key.match(new RegExp(`^${prefix}(\\d+)$`)); + if (!match) { + throw Fail`bad key: ${key}`; + } + const num = Number(match[1]); + (Number.isSafeInteger(num) && num >= 0) || Fail`bad key: ${key}`; + return num; +}; + +/** + * Extracts portfolio ID number from a portfolio key (e.g., a vstorage path + * segment). + * @param {`portfolio${number}`} portfolioKey + */ +export const portfolioIdFromKey = portfolioKey => + parseSuffixNumber('portfolio', portfolioKey); + +/** + * Extracts flow ID number from a flow key (e.g., a vstorage path segment). + * @param {`flow${number}`} flowKey + */ +export const flowIdFromKey = flowKey => parseSuffixNumber('flow', flowKey); + +/** + * Extracts portfolio ID number from a vstorage path. + * + * @param {string | string[]} path - Either a dot-separated string or array of path segments + * @returns {number} Portfolio ID number + */ +export const portfolioIdOfPath = path => { + const segments = typeof path === 'string' ? path.split('.') : path; + const where = segments.indexOf('portfolios'); + where >= 0 || Fail`bad path: ${path}`; + const segment = segments[where + 1]; + return portfolioIdFromKey(/** @type {`portfolio${number}`} */ (segment)); +}; + +export const FlowDetailShape = M.or( + { type: 'withdraw', amount: AnyNatAmountShape }, + { type: 'deposit', amount: AnyNatAmountShape }, + { type: 'rebalance' }, +); + +/** ChainNames including those in future upgrades */ +const ChainNameExtShape = M.string(); + +/** @type {Pattern} */ +export const PortfolioStatusShapeExt = M.splitRecord( + { + positionKeys: M.arrayOf(PoolKeyShapeExt), + flowCount: M.number(), + accountIdByChain: M.recordOf(ChainNameExtShape, M.string()), + policyVersion: M.number(), + rebalanceCount: M.number(), + }, + { + depositAddress: M.string(), + nobleForwardingAddress: M.string(), + targetAllocation: TargetAllocationShapeExt, + accountsPending: M.arrayOf(ChainNameExtShape), + flowsRunning: M.recordOf(M.string(), FlowDetailShape), + }, +); + +/** + * Creates vstorage path for position transfer history. + * + * Position tracking shows transfer history per yield protocol. + * + * @param {number} parent - Portfolio ID + * @param {string} key - PoolKey + * @returns {readonly [string, 'positions', string]} + */ +export const makePositionPath = (parent, key) => [ + `portfolio${parent}`, + 'positions', + key, +]; + +/** @type {Pattern} */ +export const PositionStatusShape = M.splitRecord( + { + protocol: M.or(...Object.keys(YieldProtocol)), // YieldProtocol + accountId: M.string(), + totalIn: AnyNatAmountShape, + totalOut: AnyNatAmountShape, + }, + { + netTransfers: AnyNatAmountShape, // XXX obsolete + }, +); + +/** + * Creates vstorage path for flow operation logging. + * + * Flow logging provides real-time operation progress for transparency. + * + * @param {number} parent - Portfolio ID + * @param {number} id - Flow ID within the portfolio + * @returns {readonly [string, 'flows', string]} + */ +export const makeFlowPath = (parent, id) => [ + `portfolio${parent}`, + 'flows', + `flow${id}`, +]; + +export const makeFlowStepsPath = (parent, id, prop = 'steps') => [ + `portfolio${parent}`, + 'flows', + `flow${id}`, + prop, +]; + +const FlowDetailsProps = { + type: M.string(), + amount: AnyNatAmountShape, +}; + +/** @type {Pattern} */ +export const FlowStatusShape = M.or( + M.splitRecord( + { state: 'run', step: M.number(), how: M.string() }, + { steps: M.arrayOf(M.number()), ...FlowDetailsProps }, + ), + { state: 'undo', step: M.number(), how: M.string() }, // XXX Not currently used + M.splitRecord({ state: 'done' }, FlowDetailsProps), + M.splitRecord( + { state: 'fail', step: M.number(), how: M.string(), error: M.string() }, + { + next: M.record(), // XXX recursive pattern + where: M.string(), + ...FlowDetailsProps, + }, + {}, + ), +); + +/** @type {Pattern} */ +export const FlowStepsShape = M.arrayOf({ + how: M.string(), + amount: AnyNatAmountShape, + src: M.string(), + dest: M.string(), +}); +// #endregion + +// #region Derived reading utilities (latest entry only) + +/** + * @typedef {(path: string) => Promise} VstorageReadLatest + * @typedef {(path: string) => Promise} VstorageListChildren + */ + +/** + * @typedef {object} FlowNodeLatest + * @property {`flow${number}`} flowKey + * @property {FlowDetail | undefined} detail + * @property {StatusFor['flow'] | undefined} status + * @property {FlowStep[] | undefined} steps + * @property {StatusFor['flowOrder'] | undefined} order + * @property {'init' | 'running' | 'done' | 'fail' | 'unknown'} phase + */ + +/** + * @param {FlowDetail | undefined} detail + * @param {StatusFor['flow'] | undefined} status + * @returns {FlowNodeLatest['phase']} + */ +const deriveFlowPhase = (detail, status) => { + if (!status) return detail ? 'init' : 'unknown'; + switch (status.state) { + case 'run': + case 'undo': + return 'running'; + case 'done': + return 'done'; + case 'fail': + return 'fail'; + default: + return 'unknown'; + } +}; + +/** + * @param {string} key + * @returns {`flow${number}` | undefined} + */ +const toFlowKey = key => { + try { + return `flow${flowIdFromKey(/** @type {any} */ (key))}`; + } catch { + return undefined; + } +}; + +/** + * @typedef {object} PortfolioPositionLatest + * @property {PoolKey} poolKey + * @property {PoolPlaceInfo | undefined} place + * @property {StatusFor['position'] | undefined} status + * @property {SupportedChain | undefined} chainName + * @property {AccountId | undefined} accountId + */ + +/** + * @typedef {object} ChainPositionsLatest + * @property {SupportedChain} chainName + * @property {AccountId | undefined} accountId + * @property {readonly PortfolioPositionLatest[]} positions + */ + +/** + * @typedef {object} MaterializedPortfolioPositions + * @property {Partial>} positions + * @property {Partial>} positionsByChain + */ + +/** + * @typedef {object} PortfolioLatestSnapshot + * @property {PortfolioKey} portfolioKey + * @property {StatusFor['portfolio']} status + * @property {Record<`flow${number}`, FlowNodeLatest>} flows + * @property {Partial>} [positions] + * @property {Partial>} [positionsByChain] + */ + +/** + * @typedef {object} PortfolioChainAccountPlace + * @property {'chain'} kind + * @property {SupportedChain} chainName + * @property {AccountId | undefined} accountId + * @property {AssetPlaceRef} place + */ + +/** + * @typedef {object} PortfolioPositionPlace + * @property {'position'} kind + * @property {PoolKey} poolKey + * @property {PoolPlaceInfo | undefined} pool + * @property {SupportedChain | undefined} chainName + * @property {AccountId | undefined} accountId + * @property {PoolKey} place + */ + +/** + * @typedef {object} PortfolioHistoryEntryBase + * @property {bigint} blockHeight + * @property {string} path + */ + +/** + * @typedef {PortfolioHistoryEntryBase & { kind: 'portfolio'; value: StatusFor['portfolio']; }} PortfolioHistoryEntryPortfolio + * @typedef {PortfolioHistoryEntryBase & { kind: 'flow'; flowKey: `flow${number}`; value: StatusFor['flow']; }} PortfolioHistoryEntryFlow + * @typedef {PortfolioHistoryEntryBase & { kind: 'position'; poolKey: PoolKey; value: StatusFor['position']; }} PortfolioHistoryEntryPosition + */ + +/** @typedef {PortfolioHistoryEntryPortfolio | PortfolioHistoryEntryFlow | PortfolioHistoryEntryPosition} PortfolioHistoryEvent */ + +/** + * @param {object} opts + * @param {VstorageReadLatest} opts.readLatest + * @param {string} opts.portfolioPath + * @param {readonly PoolKey[]} opts.positionKeys + * @returns {Promise>>} + */ +const readPositionNodesLatest = async ({ + readLatest, + portfolioPath, + positionKeys, +}) => { + if (!positionKeys.length) { + return harden({}); + } + const entries = await Promise.all( + positionKeys.map(async poolKey => { + try { + const position = await readLatest( + `${portfolioPath}.positions.${poolKey}`, + ); + return /** @type {[PoolKey, StatusFor['position']]} */ ([ + poolKey, + /** @type {StatusFor['position']} */ (position), + ]); + } catch { + return /** @type {[PoolKey, undefined]} */ ([poolKey, undefined]); + } + }), + ); + return harden(Object.fromEntries(entries)); +}; + +/** + * @param {object} opts + * @param {StatusFor['portfolio']} opts.status + * @param {Partial>} opts.positionNodes + * @param {Record} [opts.poolPlaces] + * @returns {MaterializedPortfolioPositions} + */ +export const materializePortfolioPositions = ({ + status, + positionNodes, + poolPlaces = /** @type {Record} */ (PoolPlaces), +}) => { + const { positionKeys = [], accountIdByChain = {} } = status; + /** @type {Partial>} */ + const positions = {}; + /** @type {Map} */ + const positionsByChainEntries = new Map(); + + for (const poolKey of positionKeys) { + const place = /** @type {PoolPlaceInfo | undefined} */ ( + poolPlaces[poolKey] + ); + const chainName = /** @type {SupportedChain | undefined} */ ( + place?.chainName + ); + const positionStatus = positionNodes[poolKey]; + const accountId = + positionStatus?.accountId ?? + (chainName ? accountIdByChain[chainName] : undefined); + const entry = harden({ + poolKey, + place, + status: positionStatus, + chainName, + accountId, + }); + positions[poolKey] = entry; + if (chainName) { + if (!positionsByChainEntries.has(chainName)) { + positionsByChainEntries.set(chainName, []); + } + positionsByChainEntries.get(chainName).push(entry); + } + } + + for (const chainName of keys(accountIdByChain)) { + const supportedChain = /** @type {SupportedChain} */ (chainName); + if (!positionsByChainEntries.has(supportedChain)) { + positionsByChainEntries.set(supportedChain, []); + } + } + + /** @type {Partial>} */ + const positionsByChain = {}; + for (const [chainName, chainPositions] of positionsByChainEntries.entries()) { + positionsByChain[chainName] = harden({ + chainName, + accountId: accountIdByChain[chainName], + positions: harden(chainPositions), + }); + } + + return harden({ + positions: harden(positions), + positionsByChain: harden(positionsByChain), + }); +}; + +/** + * @param {object} opts + * @param {StatusFor['portfolio']} opts.status + * @param {Record} [opts.poolPlaces] + * @returns {{ chainAccounts: readonly PortfolioChainAccountPlace[], positions: readonly PortfolioPositionPlace[] }} + */ +export const enumeratePortfolioPlaces = ({ + status, + poolPlaces = /** @type {Record} */ (PoolPlaces), +}) => { + const { accountIdByChain = {}, positionKeys = [] } = status; + + /** @type {PortfolioChainAccountPlace[]} */ + const chainAccounts = []; + for (const [chainName, accountId] of entries(accountIdByChain)) { + chainAccounts.push( + harden({ + kind: 'chain', + chainName: /** @type {SupportedChain} */ (chainName), + accountId, + place: /** @type {AssetPlaceRef} */ (`@${chainName}`), + }), + ); + } + + /** @type {PortfolioPositionPlace[]} */ + const positions = []; + for (const poolKey of positionKeys) { + const pool = poolPlaces[poolKey]; + const chainName = /** @type {SupportedChain | undefined} */ ( + pool?.chainName + ); + const accountId = chainName ? accountIdByChain[chainName] : undefined; + positions.push( + harden({ + kind: 'position', + poolKey, + pool, + chainName, + accountId, + place: poolKey, + }), + ); + } + + return harden({ + chainAccounts: harden(chainAccounts), + positions: harden(positions), + }); +}; + +/** + * Read the latest portfolio + flow state, combining `flowsRunning` (portfolio + * node) with any flow nodes under `.flows`. Derived `phase` is aligned to the + * planner expectations: + * - `init` when present in `flowsRunning` but no flow node is written yet + * - `running`, `done`, `fail` when a flow node is present + * When `includePositions` is true the snapshot also returns `positions` and + * `positionsByChain`, materializing `positionKeys` + `positions.*` nodes with + * PoolPlaces metadata and `accountIdByChain`. + */ +export const readPortfolioLatest = async ({ + readLatest, + listChildren, + portfoliosPathPrefix, + portfolioKey, + includeSteps = true, + includePositions = false, + poolPlaces = /** @type {Record} */ (PoolPlaces), +}) => { + const portfolioPath = `${portfoliosPathPrefix}.${portfolioKey}`; + /** @type {StatusFor['portfolio']} */ + const status = await readLatest(portfolioPath); + + const running = status.flowsRunning || {}; + /** @type {`flow${number}`[]} */ + const runningKeys = /** @type {any} */ (Object.keys(running)); + + const flowChildren = listChildren + ? await listChildren(`${portfolioPath}.flows`) + : []; + /** @type {Set<`flow${number}`>} */ + const flowKeys = new Set(); + for (const key of [...runningKeys, ...flowChildren]) { + const flowKey = toFlowKey(key); + if (flowKey) flowKeys.add(flowKey); + } + + /** @type {Record<`flow${number}`, FlowNodeLatest>} */ + const flows = {}; + for (const flowKey of flowKeys) { + const detailFromRunning = running[flowKey]; + /** @type {StatusFor['flow'] | undefined} */ + let statusNode; + /** @type {FlowStep[] | undefined} */ + let steps; + /** @type {StatusFor['flowOrder'] | undefined} */ + let order; + try { + statusNode = await readLatest(`${portfolioPath}.flows.${flowKey}`); + } catch { + statusNode = undefined; + } + if (includeSteps) { + try { + steps = await readLatest(`${portfolioPath}.flows.${flowKey}.steps`); + } catch { + steps = undefined; + } + try { + order = await readLatest(`${portfolioPath}.flows.${flowKey}.order`); + } catch { + order = undefined; + } + } + + flows[flowKey] = harden({ + flowKey, + detail: + statusNode && 'type' in statusNode && statusNode.type + ? /** @type {FlowDetail} */ (statusNode) + : detailFromRunning, + status: statusNode, + steps, + order, + phase: deriveFlowPhase(detailFromRunning, statusNode), + }); + } + + let positions; + let positionsByChain; + if (includePositions) { + const positionNodes = await readPositionNodesLatest({ + readLatest, + portfolioPath, + positionKeys: status.positionKeys || [], + }); + const materialized = materializePortfolioPositions({ + status, + positionNodes, + poolPlaces, + }); + positions = materialized.positions; + positionsByChain = materialized.positionsByChain; + } + + return harden({ + portfolioKey, + status, + flows, + ...(includePositions + ? { positions: positions ?? harden({}), positionsByChain } + : {}), + }); +}; +// #endregion + +// #region Flow selectors + history helpers + +/** + * @param {PortfolioLatestSnapshot} snapshot + * @returns {readonly FlowNodeLatest[]} + */ +export const selectPendingFlows = snapshot => + harden( + values(snapshot.flows).filter( + flowNode => flowNode && flowNode.detail && !flowNode.status, + ), + ); + +/** + * @typedef {object} VstorageReadAtResponse + * @property {number | bigint} blockHeight + * @property {readonly unknown[]} values + */ + +/** + * @typedef {(path: string, height?: number | bigint) => Promise} VstorageReadAt + * @typedef {(value: unknown, index: number, blockHeight: bigint) => unknown} VstorageValueDecoder + */ + +const coerceHeightToBigInt = height => + typeof height === 'bigint' ? height : BigInt(height || 0); + +/** + * Iterate a vstorage history stream from the latest entry down to an optional + * minimum height. This wraps `vstorage.readAt` semantics into an async + * generator so tests and production consumers can share the same culling logic. + * + * @param {object} opts + * @param {VstorageReadAt} opts.readAt + * @param {string} opts.path + * @param {bigint | number} [opts.minHeight] + * @param {VstorageValueDecoder} [opts.decodeValue] + * @returns {AsyncGenerator<{blockHeight: bigint, values: readonly unknown[]}>} + */ +export const iterateVstorageHistory = async function* iterateVstorageHistory({ + readAt, + path, + minHeight, + decodeValue = value => value, +}) { + const minHeightBig = + minHeight === undefined ? undefined : coerceHeightToBigInt(minHeight); + /** @type {number | bigint | undefined} */ + let cursor; + + while (true) { + /** @type {{ blockHeight: number | bigint; values: readonly unknown[] }} */ + let cell; + try { + cell = await readAt(path, cursor); + } catch (err) { + const message = err && typeof err === 'object' ? err.message : undefined; + const isHistoryGap = + (typeof message === 'string' && + /no history|unknown request|Unexpected end of JSON input/i.test( + message, + )) || + (err && err.code === 18 && err.codespace === 'sdk'); + if (isHistoryGap) { + break; + } + throw err; + } + const entryHeight = coerceHeightToBigInt(cell.blockHeight); + const decodedValues = (cell.values || []).map((value, index) => + decodeValue(value, index, entryHeight), + ); + yield harden({ blockHeight: entryHeight, values: harden(decodedValues) }); + if (entryHeight === 0n) break; + if (minHeightBig !== undefined && entryHeight <= minHeightBig) break; + const nextHeight = entryHeight - 1n; + if (nextHeight < 0n) break; + cursor = + nextHeight <= Number.MAX_SAFE_INTEGER ? Number(nextHeight) : nextHeight; + } +}; + +/** + * In-memory mock storage for exercising `readPortfolioLatest` and related + * helpers. Tests can seed paths via `initialEntries` and call `writeLatest` + * while reusing the same `readLatest`/`listChildren` implementations that + * production helpers expect. + * + * @param {Record} [initialEntries] + */ +export const makeMockVstorageReaders = (initialEntries = {}) => { + const store = new Map(entries(initialEntries)); + const readLatest = async path => { + if (!store.has(path)) throw Fail`missing mock entry for ${path}`; + return store.get(path); + }; + const writeLatest = (path, value) => { + store.set(path, value); + }; + const listChildren = async path => { + const prefix = `${path}.`; + const children = new Set(); + for (const key of store.keys()) { + if (!key.startsWith(prefix)) continue; + children.add(key.slice(prefix.length).split('.')[0]); + } + return [...children]; + }; + return harden({ readLatest, listChildren, writeLatest, store }); +}; +// #endregion + +/** + * Read chronological history entries for a portfolio and its flows using + * `vstorage.readAt`. Values are decoded via the provided `decodeValue` + * function, which should typically marshal capdata into plain JS objects. + * + * @param {object} opts + * @param {VstorageReadAt} opts.readAt + * @param {(path: string) => Promise} opts.listChildren + * @param {string} opts.portfoliosPathPrefix + * @param {PortfolioKey} opts.portfolioKey + * @param {(value: unknown, index: number, blockHeight: bigint) => any} opts.decodeValue + * @param {'asc' | 'desc'} [opts.sort] + * @returns {Promise} + */ +export const readPortfolioHistoryEntries = async ({ + readAt, + listChildren, + portfoliosPathPrefix, + portfolioKey, + decodeValue, + sort = 'asc', +}) => { + const entries = []; + const portfolioPath = `${portfoliosPathPrefix}.${portfolioKey}`; + + const collectPath = async (path, buildEntry) => { + for await (const { + blockHeight, + values: decodedValues, + } of iterateVstorageHistory({ + readAt, + path, + decodeValue, + })) { + for (const value of decodedValues) { + buildEntry(blockHeight, value, path); + } + } + }; + + await collectPath(portfolioPath, (blockHeight, value, path) => { + entries.push( + harden({ + kind: 'portfolio', + blockHeight, + value: /** @type {StatusFor['portfolio']} */ (value), + path, + }), + ); + }); + + const flowsPath = `${portfolioPath}.flows`; + let flowChildren = []; + try { + flowChildren = await listChildren(flowsPath); + } catch { + flowChildren = []; + } + for (const child of flowChildren) { + const flowKey = toFlowKey(child); + if (!flowKey) continue; + const flowPath = `${flowsPath}.${flowKey}`; + await collectPath(flowPath, (blockHeight, value, path) => { + entries.push( + harden({ + kind: 'flow', + blockHeight, + flowKey, + value: /** @type {StatusFor['flow']} */ (value), + path, + }), + ); + }); + } + + const positionsPath = `${portfolioPath}.positions`; + let positionChildren = []; + try { + positionChildren = await listChildren(positionsPath); + } catch { + positionChildren = []; + } + for (const poolKey of positionChildren) { + const positionPath = `${positionsPath}.${poolKey}`; + await collectPath(positionPath, (blockHeight, value, path) => { + entries.push( + harden({ + kind: 'position', + blockHeight, + poolKey: /** @type {PoolKey} */ (poolKey), + value: /** @type {StatusFor['position']} */ (value), + path, + }), + ); + }); + } + + const sorted = entries.sort((left, right) => + left.blockHeight === right.blockHeight + ? 0 + : left.blockHeight < right.blockHeight + ? -1 + : 1, + ); + return harden(sort === 'desc' ? sorted.slice().reverse() : sorted); +}; diff --git a/packages/portfolio-api/test/snapshots/exports.test.ts.md b/packages/portfolio-api/test/snapshots/exports.test.ts.md index 31e4e3f1719..61d0d9e65e5 100644 --- a/packages/portfolio-api/test/snapshots/exports.test.ts.md +++ b/packages/portfolio-api/test/snapshots/exports.test.ts.md @@ -11,14 +11,39 @@ Generated by [AVA](https://avajs.dev). [ 'ACCOUNT_DUST_EPSILON', 'AxelarChain', + 'BeefyPoolPlaces', 'CaipChainIds', 'Eip155ChainIds', 'EvmWalletOperationType', + 'FlowDetailShape', + 'FlowStatusShape', + 'FlowStepsShape', 'InstrumentId', + 'PoolKeyShapeExt', + 'PoolPlaces', + 'PortfolioStatusShapeExt', + 'PositionStatusShape', 'RebalanceStrategy', 'SupportedChain', + 'TargetAllocationShape', + 'TargetAllocationShapeExt', 'TxStatus', 'TxType', 'UsdcTokenIds', 'YieldProtocol', + 'enumeratePortfolioPlaces', + 'flowIdFromKey', + 'iterateVstorageHistory', + 'makeFlowPath', + 'makeFlowStepsPath', + 'makeMockVstorageReaders', + 'makeNatAmountShape', + 'makePortfolioPath', + 'makePositionPath', + 'materializePortfolioPositions', + 'portfolioIdFromKey', + 'portfolioIdOfPath', + 'readPortfolioHistoryEntries', + 'readPortfolioLatest', + 'selectPendingFlows', ] diff --git a/packages/portfolio-api/test/snapshots/exports.test.ts.snap b/packages/portfolio-api/test/snapshots/exports.test.ts.snap index e510a77ef46..b95654bb34b 100644 Binary files a/packages/portfolio-api/test/snapshots/exports.test.ts.snap and b/packages/portfolio-api/test/snapshots/exports.test.ts.snap differ diff --git a/packages/portfolio-api/test/types.test-d.ts b/packages/portfolio-api/test/types.test-d.ts index 23aa7055878..d324f2668e9 100644 --- a/packages/portfolio-api/test/types.test-d.ts +++ b/packages/portfolio-api/test/types.test-d.ts @@ -9,6 +9,15 @@ import type { SupportedChain, YieldProtocol } from '../src/constants.js'; import { AxelarChain } from '../src/constants.js'; import type { InstrumentId } from '../src/instruments.js'; import type { PublishedTx } from '../src/resolver.js'; +import { + enumeratePortfolioPlaces, + iterateVstorageHistory, + makeMockVstorageReaders, + materializePortfolioPositions, + readPortfolioHistoryEntries, + readPortfolioLatest, + selectPendingFlows, +} from '../src/vstorage-schema.js'; import type { AssetPlaceRef, FlowDetail, @@ -17,12 +26,20 @@ import type { FlowStep, InterChainAccountRef, LocalChainAccountRef, + PoolKey, PortfolioKey, ProposalType, SeatKeyword, StatusFor, TargetAllocation, } from '../src/types.js'; +import type { + FlowNodeLatest, + PortfolioChainAccountPlace, + PortfolioHistoryEvent, + PortfolioLatestSnapshot, + PortfolioPositionPlace, +} from '../src/vstorage-schema.js'; declare const natAmount: NatAmount; declare const accountId: AccountId; @@ -157,6 +174,78 @@ const status: StatusFor = { expectType(status); +declare const readLatest: (path: string) => Promise; +declare const listChildren: (path: string) => Promise; + +const positionNodes: Partial> = { + [instrumentId]: status.position, +}; + +const materialized = materializePortfolioPositions({ + status: status.portfolio, + positionNodes, +}); +expectType>>(materialized.positions); +expectType< + Partial> +>(materialized.positionsByChain); + +expectType>( + readPortfolioLatest({ + readLatest, + listChildren, + portfoliosPathPrefix: 'published.ymax0.portfolios', + portfolioKey: 'portfolio1', + includePositions: true, + }), +); + +const pending = selectPendingFlows({ + portfolioKey: 'portfolio1', + status: status.portfolio, + flows: { + flow1: { + flowKey: 'flow1', + detail: flowsRunning.flow1, + status: undefined, + steps: undefined, + order: undefined, + phase: 'init', + }, + }, + }, +}); +expectType(pending); + +const mockReaders = makeMockVstorageReaders(); +expectType>(mockReaders.readLatest('path')); +expectType>(mockReaders.listChildren('path')); + +declare const readAt: ( + path: string, + height?: number | bigint, +) => Promise<{ blockHeight: number | bigint; values: unknown[] }>; + +const historyIterator = iterateVstorageHistory({ + readAt, + path: 'published.demo', +}); +expectAssignable>(historyIterator); + +const places = enumeratePortfolioPlaces({ status: status.portfolio }); +expectType(places.positions); +expectType(places.chainAccounts); + +expectType>( + readPortfolioHistoryEntries({ + readAt, + listChildren, + portfoliosPathPrefix: 'published.ymax0.portfolios', + portfolioKey: 'portfolio1', + decodeValue: value => value, + }), +); + // Ensure every Axelar chain key is covered by SupportedChain. expectAssignable( null as unknown as keyof typeof AxelarChain, diff --git a/packages/portfolio-api/test/vstorage-history.test.ts b/packages/portfolio-api/test/vstorage-history.test.ts new file mode 100644 index 00000000000..f60bbfb826d --- /dev/null +++ b/packages/portfolio-api/test/vstorage-history.test.ts @@ -0,0 +1,39 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { iterateVstorageHistory } from '../src/vstorage-schema.js'; + +test('iterateVstorageHistory walks height history with decoder', async t => { + const heights = [5n, 4n, 3n, 1n, 0n]; + let index = 0; + const readAt = async (_path: string, _height?: number | bigint) => { + const blockHeight = heights[index++]; + if (blockHeight === 1n) { + const err = Error('Unexpected end of JSON input'); + err.code = 18; + err.codespace = 'sdk'; + throw err; + } + return { + blockHeight, + values: blockHeight + ? [JSON.stringify({ blockHeight: Number(blockHeight) })] + : [], + }; + }; + + const seen: Array<{ blockHeight: bigint; values: unknown[] }> = []; + for await (const entry of iterateVstorageHistory({ + readAt, + path: 'published.demo.node', + minHeight: 3n, + decodeValue: value => JSON.parse(value as string), + })) { + seen.push(entry); + } + + t.deepEqual( + seen.map(({ blockHeight }) => blockHeight), + [5n, 4n, 3n], + 'stops once minHeight reached', + ); + t.deepEqual(seen[0]?.values[0], { blockHeight: 5 }); +}); diff --git a/packages/portfolio-api/test/vstorage-schema.test.ts b/packages/portfolio-api/test/vstorage-schema.test.ts new file mode 100644 index 00000000000..9adb69ceca3 --- /dev/null +++ b/packages/portfolio-api/test/vstorage-schema.test.ts @@ -0,0 +1,342 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { AmountMath, makeIssuerKit } from '@agoric/ertp'; +import { mustMatch } from '@agoric/internal'; +import { + FlowDetailShape, + FlowStatusShape, + FlowStepsShape, + PortfolioStatusShapeExt, + PoolPlaces, + enumeratePortfolioPlaces, + makeFlowPath, + makePortfolioPath, + makePositionPath, + makeMockVstorageReaders, + portfolioIdFromKey, + portfolioIdOfPath, + readPortfolioHistoryEntries, + readPortfolioLatest, + selectPendingFlows, + type PortfolioLatestSnapshot, +} from '../src/vstorage-schema.js'; +import type { StatusFor } from '../src/types.js'; + +const { brand: USDC } = makeIssuerKit('USDC'); +const amount = AmountMath.make(USDC, 1n); + +const makeHistoryReadAt = history => { + const normalized = new Map( + [...history.entries()].map(([path, entries]) => { + const normalizedEntries = entries + .map(entry => ({ + blockHeight: BigInt(entry.blockHeight), + values: entry.values, + })) + .sort((a, b) => Number(a.blockHeight - b.blockHeight)); + return [path, normalizedEntries]; + }), + ); + return async (path, height) => { + const entriesForPath = normalized.get(path) || []; + const heightBig = + height === undefined ? undefined : typeof height === 'bigint' ? height : BigInt(height); + const candidates = entriesForPath.filter(entry => + heightBig === undefined ? true : entry.blockHeight <= heightBig, + ); + const entry = candidates.at(-1); + if (!entry) { + throw Error(`no history for ${path} <= ${height}`); + } + return entry; + }; +}; + +test('path helpers and parsers', t => { + t.deepEqual(makePortfolioPath(3), ['portfolio3']); + t.deepEqual(makePositionPath(2, 'USDN'), ['portfolio2', 'positions', 'USDN']); + t.deepEqual(makeFlowPath(5, 7), ['portfolio5', 'flows', 'flow7']); + t.is(portfolioIdFromKey('portfolio9'), 9); + t.is(portfolioIdOfPath('published.ymax0.portfolios.portfolio4'), 4); + t.is( + portfolioIdOfPath(['published', 'ymax0', 'portfolios', 'portfolio8']), + 8, + ); +}); + +test('shapes validate portfolio and flow data', t => { + const portfolioStatus: StatusFor['portfolio'] = harden({ + positionKeys: ['USDN'], + flowCount: 1, + accountIdByChain: { agoric: 'cosmos:agoric-3:agoric1abc' }, + policyVersion: 0, + rebalanceCount: 0, + flowsRunning: { flow1: { type: 'deposit', amount } }, + }); + t.notThrows(() => mustMatch(portfolioStatus, PortfolioStatusShapeExt)); + + const flowStatus: StatusFor['flow'] = harden({ + state: 'run', + step: 1, + how: 'deposit', + type: 'deposit', + amount, + }); + t.notThrows(() => mustMatch(flowStatus, FlowStatusShape)); + t.notThrows(() => + mustMatch( + harden([{ how: 'deposit', amount, src: '', dest: '@noble' }]), + FlowStepsShape, + ), + ); + t.notThrows(() => + mustMatch(harden({ type: 'withdraw', amount }), FlowDetailShape), + ); +}); + +test('readPortfolioLatest combines flowsRunning and flow nodes', async t => { + const portfoliosPathPrefix = 'published.ymax0.portfolios'; + const portfolioKey = 'portfolio3' as const; + const portfolioPath = `${portfoliosPathPrefix}.${portfolioKey}`; + const mock = makeMockVstorageReaders(); + + const status: StatusFor['portfolio'] = { + positionKeys: [PoolPlaces.USDN.protocol], + flowCount: 2, + accountIdByChain: { agoric: 'cosmos:agoric-3:agoric1abc' }, + policyVersion: 1, + rebalanceCount: 0, + flowsRunning: { + flow2: { type: 'deposit', amount }, + }, + }; + mock.writeLatest(portfolioPath, status); + mock.writeLatest(`${portfolioPath}.flows.flow1`, { + state: 'run', + step: 1, + how: 'deposit', + type: 'deposit', + amount, + } satisfies StatusFor['flow']); + const { readLatest, listChildren } = mock; + + const snapshot: PortfolioLatestSnapshot = await readPortfolioLatest({ + readLatest, + listChildren, + portfoliosPathPrefix, + portfolioKey, + }); + + t.deepEqual(snapshot.status, status); + t.deepEqual(Object.keys(snapshot.flows).sort(), ['flow1', 'flow2']); + t.is(snapshot.flows.flow2.phase, 'init'); + t.is(snapshot.flows.flow2.detail?.type, 'deposit'); + t.is(snapshot.flows.flow1.phase, 'running'); + t.truthy(snapshot.flows.flow1.status); +}); + +test('readPortfolioLatest materializes positions and chains when requested', async t => { + const portfoliosPathPrefix = 'published.ymax0.portfolios'; + const portfolioKey = 'portfolio5' as const; + const portfolioPath = `${portfoliosPathPrefix}.${portfolioKey}`; + const mock = makeMockVstorageReaders(); + + const status: StatusFor['portfolio'] = { + positionKeys: ['USDN', 'Aave_Ethereum', 'Compound_Base'], + flowCount: 0, + accountIdByChain: { + noble: 'cosmos:noble-1:noble1def', + Ethereum: 'eip155:1:0xabc', + Base: 'eip155:8453:0xdef', + agoric: 'cosmos:agoric-3:agoric1xyz', + }, + policyVersion: 3, + rebalanceCount: 1, + }; + mock.writeLatest(portfolioPath, status); + const usdPosition: StatusFor['position'] = { + protocol: 'USDN', + accountId: status.accountIdByChain.noble!, + totalIn: amount, + totalOut: amount, + }; + const aavePosition: StatusFor['position'] = { + protocol: 'Aave', + accountId: status.accountIdByChain.Ethereum!, + totalIn: amount, + totalOut: amount, + }; + mock.writeLatest(`${portfolioPath}.positions.USDN`, usdPosition); + mock.writeLatest(`${portfolioPath}.positions.Aave_Ethereum`, aavePosition); + const { readLatest, listChildren } = mock; + + const snapshot = await readPortfolioLatest({ + readLatest, + listChildren, + portfoliosPathPrefix, + portfolioKey, + includePositions: true, + }); + + t.truthy(snapshot.positions); + const positions = snapshot.positions ?? {}; + const positionsByChain = snapshot.positionsByChain ?? {}; + t.deepEqual(Object.keys(positions).sort(), [ + 'Aave_Ethereum', + 'Compound_Base', + 'USDN', + ]); + t.is(positions.USDN?.chainName, 'noble'); + t.is(positions.USDN?.accountId, status.accountIdByChain.noble); + t.is(positions.Compound_Base?.status, undefined); + t.is(positions.Compound_Base?.accountId, status.accountIdByChain.Base); + t.is(positions.Aave_Ethereum?.status?.protocol, 'Aave'); + t.truthy(positionsByChain.Ethereum); + t.is(positionsByChain.Base?.positions.length, 1); + t.is(positionsByChain.Base?.positions[0]?.poolKey, 'Compound_Base'); + t.true(Array.isArray(positionsByChain.agoric?.positions)); + t.is(positionsByChain.agoric?.positions.length, 0); +}); + +test('enumeratePortfolioPlaces joins account and position metadata', t => { + const status: StatusFor['portfolio'] = { + positionKeys: ['USDN', 'Aave_Ethereum'], + accountIdByChain: { + noble: 'cosmos:noble-1:noble1def', + Ethereum: 'eip155:1:0xabc', + }, + flowCount: 0, + policyVersion: 0, + rebalanceCount: 0, + } as StatusFor['portfolio']; + + const places = enumeratePortfolioPlaces({ status }); + t.is(places.chainAccounts.length, 2); + t.deepEqual(places.chainAccounts.map(({ chainName }) => chainName).sort(), [ + 'Ethereum', + 'noble', + ]); + const [usdPlace] = places.positions.filter(p => p.poolKey === 'USDN'); + t.is(usdPlace?.chainName, 'noble'); + t.is(usdPlace?.accountId, status.accountIdByChain.noble); + const aavePlace = places.positions.find(p => p.poolKey === 'Aave_Ethereum'); + t.truthy(aavePlace?.pool?.protocol); +}); + +test('readPortfolioHistoryEntries collects chronological events', async t => { + const portfoliosPathPrefix = 'published.ymax0.portfolios'; + const portfolioKey = 'portfolio1' as const; + const portfolioPath = `${portfoliosPathPrefix}.${portfolioKey}`; + const statusA: StatusFor['portfolio'] = { + positionKeys: ['USDN'], + flowCount: 0, + accountIdByChain: {}, + policyVersion: 1, + rebalanceCount: 0, + } as StatusFor['portfolio']; + const statusB: StatusFor['portfolio'] = { + ...statusA, + policyVersion: 2, + flowsRunning: { flow1: { type: 'deposit', amount } }, + }; + const flowStatus: StatusFor['flow'] = { + state: 'run', + step: 1, + how: 'deposit', + type: 'deposit', + amount, + }; + const history = new Map([ + [ + portfolioPath, + [ + { blockHeight: 12n, values: [statusB] }, + { blockHeight: 8n, values: [statusA] }, + ], + ], + [ + `${portfolioPath}.flows.flow1`, + [{ blockHeight: 10n, values: [flowStatus] }], + ], + [ + `${portfolioPath}.positions.USDN`, + [ + { + blockHeight: 11n, + values: [ + { + protocol: 'USDN', + accountId: statusA.accountIdByChain?.noble, + totalIn: AmountMath.make(USDC, 10n), + totalOut: AmountMath.make(USDC, 1n), + } satisfies StatusFor['position'], + ], + }, + ], + ], + ]); + const readAt = makeHistoryReadAt(history); + const listChildren = async path => { + if (path.endsWith('.flows')) return ['flow1']; + if (path.endsWith('.positions')) return ['USDN']; + return []; + }; + const events = await readPortfolioHistoryEntries({ + readAt, + listChildren, + portfoliosPathPrefix, + portfolioKey, + decodeValue: value => value, + }); + + t.deepEqual( + events.map(entry => [entry.kind, entry.blockHeight]), + [ + ['portfolio', 8n], + ['flow', 10n], + ['position', 11n], + ['portfolio', 12n], + ], + ); +}); + +test('selectPendingFlows filters flows without nodes', async t => { + const snapshot: PortfolioLatestSnapshot = { + portfolioKey: 'portfolio7', + status: /** @type {StatusFor['portfolio']} */ { + positionKeys: [], + flowCount: 0, + accountIdByChain: {}, + policyVersion: 0, + rebalanceCount: 0, + flowsRunning: {}, + }, + flows: { + flow1: { + flowKey: 'flow1', + detail: { type: 'deposit', amount }, + status: undefined, + steps: undefined, + order: undefined, + phase: 'init', + }, + flow2: { + flowKey: 'flow2', + detail: { type: 'withdraw', amount }, + status: { + state: 'run', + step: 1, + how: 'withdraw', + type: 'withdraw', + amount, + } as StatusFor['flow'], + steps: undefined, + order: undefined, + phase: 'running', + }, + }, + } as any; + + const pending = selectPendingFlows(snapshot); + t.is(pending.length, 1); + t.is(pending[0]?.flowKey, 'flow1'); +}); diff --git a/packages/portfolio-contract/src/portfolio.flows.ts b/packages/portfolio-contract/src/portfolio.flows.ts index 87151ee4555..cf8f62787cf 100644 --- a/packages/portfolio-contract/src/portfolio.flows.ts +++ b/packages/portfolio-contract/src/portfolio.flows.ts @@ -76,11 +76,11 @@ import { } from './type-guards-steps.ts'; import { PoolPlaces, - type EVMContractAddressesMap, type FlowDetail, type PoolKey, type ProposalType, -} from './type-guards.ts'; +} from '@agoric/portfolio-api'; +import type { EVMContractAddressesMap } from './type-guards.ts'; import { runJob, type Job } from './schedule-order.ts'; // XXX: import { VaultType } from '@agoric/cosmic-proto/dist/codegen/noble/dollar/vaults/v1/vaults'; diff --git a/packages/portfolio-contract/src/type-guards-steps.ts b/packages/portfolio-contract/src/type-guards-steps.ts index 7dbf5afe227..8a9475be459 100644 --- a/packages/portfolio-contract/src/type-guards-steps.ts +++ b/packages/portfolio-contract/src/type-guards-steps.ts @@ -11,11 +11,11 @@ import { } from '@agoric/portfolio-api/src/constants.js'; import { M } from '@endo/patterns'; import { - makeNatAmountShape, PoolPlaces, TargetAllocationShape, type TargetAllocation, -} from './type-guards.ts'; + makeNatAmountShape, +} from '@agoric/portfolio-api'; const { keys, values } = Object; diff --git a/packages/portfolio-contract/src/type-guards.ts b/packages/portfolio-contract/src/type-guards.ts index e54547b8c21..85dad8a810f 100644 --- a/packages/portfolio-contract/src/type-guards.ts +++ b/packages/portfolio-contract/src/type-guards.ts @@ -20,20 +20,12 @@ * * For usage examples, see `makeTrader` in {@link ../test/portfolio-actors.ts}. */ -import type { Brand, NatValue } from '@agoric/ertp'; +import type { Brand } from '@agoric/ertp'; import type { TypedPattern } from '@agoric/internal'; -import { stripPrefix, tryNow } from '@agoric/internal/src/ses-utils.js'; -import { - AnyNatAmountShape, - type AccountId, - type Bech32Address, -} from '@agoric/orchestration'; import { AxelarChain, - YieldProtocol, - type AssetPlaceRef, + makeNatAmountShape, type FlowDetail, - type InstrumentId, type ProposalType, type StatusFor, type TargetAllocation, @@ -42,26 +34,11 @@ import type { ContinuingInvitationSpec, ContractInvitationSpec, } from '@agoric/smart-wallet/src/invitations.js'; -import { Fail } from '@endo/errors'; -import { isNat } from '@endo/nat'; import { M } from '@endo/patterns'; import type { EVMContractAddresses } from './portfolio.contract.js'; export type { OfferArgsFor } from './type-guards-steps.js'; -// #region preliminaries -const { keys } = Object; - -/** no runtime validation */ -const AnyString = <_T>() => M.string(); - -/** - * @param brand must be a 'nat' brand, not checked - */ -export const makeNatAmountShape = (brand: Brand<'nat'>, min?: NatValue) => - harden({ brand, value: min ? M.gte(min) : M.nat() }); -// #endregion - // #region Proposal Shapes export const makeProposalShapes = ( @@ -103,239 +80,28 @@ export const makeProposalShapes = ( harden(makeProposalShapes); // #endregion -// #region Offer Args - -export type PoolPlaceInfo = - | { protocol: 'USDN'; vault: null | 1; chainName: 'noble' } - | { protocol: YieldProtocol; chainName: AxelarChain }; - -// XXX special handling. What's the functional difference from other places? -export const BeefyPoolPlaces = { - Beefy_re7_Avalanche: { - protocol: 'Beefy', - chainName: 'Avalanche', - }, - Beefy_morphoGauntletUsdc_Ethereum: { - protocol: 'Beefy', - chainName: 'Ethereum', - }, - Beefy_morphoSmokehouseUsdc_Ethereum: { - protocol: 'Beefy', - chainName: 'Ethereum', - }, - Beefy_morphoSeamlessUsdc_Base: { - protocol: 'Beefy', - chainName: 'Base', - }, - Beefy_compoundUsdc_Optimism: { - protocol: 'Beefy', - chainName: 'Optimism', - }, - Beefy_compoundUsdc_Arbitrum: { - protocol: 'Beefy', - chainName: 'Arbitrum', - }, -} as const satisfies Partial>; - -export const PoolPlaces = { - USDN: { protocol: 'USDN', vault: null, chainName: 'noble' }, // MsgSwap only - USDNVault: { protocol: 'USDN', vault: 1, chainName: 'noble' }, // MsgSwap, MsgLock - Aave_Avalanche: { protocol: 'Aave', chainName: 'Avalanche' }, - Aave_Ethereum: { protocol: 'Aave', chainName: 'Ethereum' }, - Aave_Optimism: { protocol: 'Aave', chainName: 'Optimism' }, - Aave_Arbitrum: { protocol: 'Aave', chainName: 'Arbitrum' }, - Aave_Base: { protocol: 'Aave', chainName: 'Base' }, - Compound_Ethereum: { protocol: 'Compound', chainName: 'Ethereum' }, - Compound_Optimism: { protocol: 'Compound', chainName: 'Optimism' }, - Compound_Arbitrum: { protocol: 'Compound', chainName: 'Arbitrum' }, - Compound_Base: { protocol: 'Compound', chainName: 'Base' }, - ...BeefyPoolPlaces, -} as const satisfies Record; -harden(PoolPlaces); - -/** - * Names of places where a portfolio may have a position. - */ -export type PoolKey = InstrumentId; - -/** Ext for Extensible: includes PoolKeys in future upgrades */ -export type PoolKeyExt = string; - -/** Ext for Extensible: includes PoolKeys in future upgrades */ -export const PoolKeyShapeExt = M.string(); - -export const TargetAllocationShape: TypedPattern = M.recordOf( - M.or(...keys(PoolPlaces)), - M.nat(), -); - -export const TargetAllocationShapeExt: TypedPattern> = - M.recordOf(PoolKeyShapeExt, M.nat()); - -// #endregion - -// #region ymax0 vstorage keys and values -// XXX the vstorage path API is kinda awkward to use; see ymax-deploy.test.ts - -/** - * Creates vstorage path for portfolio status under published.ymax0. - * - * Portfolio status includes position counts, account mappings, and flow history. - * - * @param id - Portfolio ID number - * @returns Path segments for vstorage - */ -export const makePortfolioPath = (id: number): [`portfolio${number}`] => [ - `portfolio${id}`, -]; - -/** - * Extracts portfolio ID number from a portfolio key (e.g., a vstorage path - * segment). - */ -export const portfolioIdFromKey = (portfolioKey: `portfolio${number}`) => { - // TODO: const strId = stripPrefix('portfolio', portfolioKey); - // TODO: const id = Number(strId); - const id = Number(portfolioKey.replace(/^portfolio/, '')); - isNat(id) || Fail`bad key: ${portfolioKey}`; - return id; -}; - -/** - * Extracts flow ID number from a flow key (e.g., a vstorage path segment). - */ -export const flowIdFromKey = (flowKey: `flow${number}`) => { - const strId = stripPrefix('flow', flowKey); - const id = Number(strId); - isNat(id) || Fail`bad key: ${flowKey}`; - return id; -}; - -/** - * Extracts portfolio ID number from a vstorage path. - * - * @param path - Either a dot-separated string or array of path segments - * @returns Portfolio ID number - */ -export const portfolioIdOfPath = (path: string | string[]) => { - const segments = typeof path === 'string' ? path.split('.') : path; - const where = segments.indexOf('portfolios'); - where >= 0 || Fail`bad path: ${path}`; - const segment = segments[where + 1]; - return tryNow( - () => portfolioIdFromKey(segment as any), - _err => Fail`bad path: ${path}`, - ); -}; - -export const FlowDetailShape: TypedPattern = M.or( - { type: 'withdraw', amount: AnyNatAmountShape }, - { type: 'deposit', amount: AnyNatAmountShape }, - { type: 'rebalance' }, -); - -/** ChainNames including those in future upgrades */ -type ChainNameExt = string; -const ChainNameExtShape: TypedPattern = M.string(); - -export const PortfolioStatusShapeExt: TypedPattern = - M.splitRecord( - { - positionKeys: M.arrayOf(PoolKeyShapeExt), - flowCount: M.number(), - accountIdByChain: M.recordOf(ChainNameExtShape, AnyString()), - policyVersion: M.number(), - rebalanceCount: M.number(), - }, - { - depositAddress: AnyString(), - nobleForwardingAddress: AnyString(), - targetAllocation: TargetAllocationShapeExt, - accountsPending: M.arrayOf(ChainNameExtShape), - flowsRunning: M.recordOf(AnyString<`flow${number}`>(), FlowDetailShape), - }, - ); - -/** - * Creates vstorage path for position transfer history. - * - * Position tracking shows transfer history per yield protocol. - * Used by {@link Position.publishStatus} to publish position state. - * - * @param parent - Portfolio ID - * @param key - PoolKey - * @returns Path segments for vstorage - */ -export const makePositionPath = (parent: number, key: PoolKeyExt) => [ - `portfolio${parent}`, - 'positions', - key, -]; - -export const PositionStatusShape: TypedPattern = - M.splitRecord( - { - protocol: M.or(...Object.keys(YieldProtocol)), // YieldProtocol - accountId: AnyString(), - totalIn: AnyNatAmountShape, - totalOut: AnyNatAmountShape, - }, - { - netTransfers: AnyNatAmountShape, // XXX obsolete - }, - ); - -/** - * Creates vstorage path for flow operation logging. - * - * Flow logging provides real-time operation progress for transparency. - * Used by {@link PortfolioKit.reporter.publishFlowStatus} to track rebalancing operations. - * - * @param parent - Portfolio ID - * @param id - Flow ID within the portfolio - * @returns Path segments for vstorage - */ -export const makeFlowPath = (parent: number, id: number) => [ - `portfolio${parent}`, - 'flows', - `flow${id}`, -]; - -export const makeFlowStepsPath = ( - parent: number, - id: number, - prop: 'steps' | 'order' = 'steps', -) => [`portfolio${parent}`, 'flows', `flow${id}`, prop]; - -const FlowDetailsProps = { - type: M.string(), - amount: AnyNatAmountShape, -}; - -export const FlowStatusShape: TypedPattern = M.or( - M.splitRecord( - { state: 'run', step: M.number(), how: M.string() }, - { steps: M.arrayOf(M.number()), ...FlowDetailsProps }, - ), - { state: 'undo', step: M.number(), how: M.string() }, // XXX Not currently used - M.splitRecord({ state: 'done' }, FlowDetailsProps), - M.splitRecord( - { state: 'fail', step: M.number(), how: M.string(), error: M.string() }, - { - next: M.record(), // XXX recursive pattern - where: AnyString(), // XXX obsolete - ...FlowDetailsProps, - }, - {}, - ), -); - -export const FlowStepsShape: TypedPattern = M.arrayOf({ - how: M.string(), - amount: AnyNatAmountShape, - src: AnyString(), - dest: AnyString(), -}); +// #region vstorage helpers (re-exported from portfolio-api) +export { + BeefyPoolPlaces, + FlowDetailShape, + FlowStatusShape, + FlowStepsShape, + PoolKeyShapeExt, + PoolPlaces, + PortfolioStatusShapeExt, + PositionStatusShape, + TargetAllocationShape, + TargetAllocationShapeExt, + flowIdFromKey, + makeFlowPath, + makeFlowStepsPath, + makeNatAmountShape, + makePortfolioPath, + makePositionPath, + portfolioIdFromKey, + portfolioIdOfPath, +} from '@agoric/portfolio-api'; +export type { PoolKey, PoolKeyExt } from '@agoric/portfolio-api'; // #endregion // XXX deployment concern, not part of contract external interface diff --git a/packages/portfolio-contract/test/network/prod-network.test.ts b/packages/portfolio-contract/test/network/prod-network.test.ts index e2de69b3890..a27d1b2087b 100644 --- a/packages/portfolio-contract/test/network/prod-network.test.ts +++ b/packages/portfolio-contract/test/network/prod-network.test.ts @@ -13,7 +13,7 @@ import { import PROD_NETWORK, { PROD_NETWORK as NAMED_PROD, } from '../../tools/network/network.prod.js'; -import { PoolPlaces } from '../../src/type-guards.js'; +import { PoolPlaces } from '@agoric/portfolio-api'; import type { AssetPlaceRef } from '../../src/type-guards-steps.js'; import type { PoolKey, diff --git a/packages/portfolio-contract/test/offer-shapes.test.ts b/packages/portfolio-contract/test/offer-shapes.test.ts index 80299aaed3d..8b5bb789e6f 100644 --- a/packages/portfolio-contract/test/offer-shapes.test.ts +++ b/packages/portfolio-contract/test/offer-shapes.test.ts @@ -5,15 +5,17 @@ import { AmountMath, makeIssuerKit } from '@agoric/ertp'; import { withAmountUtils } from '@agoric/zoe/tools/test-utils.js'; import { matches, mustMatch } from '@endo/patterns'; import { makeOfferArgsShapes } from '../src/type-guards-steps.ts'; +import { + makeProposalShapes, +} from '../src/type-guards.ts'; import { FlowStatusShape, FlowStepsShape, - makeProposalShapes, PoolKeyShapeExt, PortfolioStatusShapeExt, PositionStatusShape, type StatusFor, -} from '../src/type-guards.ts'; +} from '@agoric/portfolio-api'; const usdcKit = withAmountUtils(makeIssuerKit('USDC')); const usdc = usdcKit.make; diff --git a/packages/portfolio-contract/test/plan-deposit-transfers.test.ts b/packages/portfolio-contract/test/plan-deposit-transfers.test.ts index c225f9f72fc..9bacae66fa9 100644 --- a/packages/portfolio-contract/test/plan-deposit-transfers.test.ts +++ b/packages/portfolio-contract/test/plan-deposit-transfers.test.ts @@ -1,7 +1,7 @@ import test from 'ava'; import { AmountMath, type Brand } from '@agoric/ertp'; import { Far } from '@endo/pass-style'; -import type { TargetAllocation } from '@aglocal/portfolio-contract/src/type-guards.js'; +import type { TargetAllocation } from '@agoric/portfolio-api'; import { makeTracer } from '@agoric/internal'; import { planDepositTransfers } from '../tools/plan-transfers.ts'; diff --git a/packages/portfolio-contract/test/planner.exo.test.ts b/packages/portfolio-contract/test/planner.exo.test.ts index 2acda0ad156..d78e5335a12 100644 --- a/packages/portfolio-contract/test/planner.exo.test.ts +++ b/packages/portfolio-contract/test/planner.exo.test.ts @@ -13,7 +13,7 @@ import { makeOfferArgsShapes, type MovementDesc, } from '../src/type-guards-steps.ts'; -import type { StatusFor } from '../src/type-guards.ts'; +import type { StatusFor } from '@agoric/portfolio-api'; const { brand: USDC } = makeIssuerKit('USDC'); diff --git a/packages/portfolio-contract/test/portfolio.contract.test.ts b/packages/portfolio-contract/test/portfolio.contract.test.ts index ebf5310b08d..fe69a2b4107 100644 --- a/packages/portfolio-contract/test/portfolio.contract.test.ts +++ b/packages/portfolio-contract/test/portfolio.contract.test.ts @@ -17,9 +17,8 @@ import { E, passStyleOf } from '@endo/far'; import type { AssetPlaceRef } from '../src/type-guards-steps.ts'; import type { OfferArgsFor, - StatusFor, - TargetAllocation, } from '../src/type-guards.ts'; +import type { StatusFor, TargetAllocation } from '@agoric/portfolio-api'; import { plannerClientMock } from '../tools/agents-mock.ts'; import { deploy, diff --git a/packages/portfolio-contract/test/portfolio.exo.test.ts b/packages/portfolio-contract/test/portfolio.exo.test.ts index 7e43f03367c..9f86d73a282 100644 --- a/packages/portfolio-contract/test/portfolio.exo.test.ts +++ b/packages/portfolio-contract/test/portfolio.exo.test.ts @@ -11,7 +11,7 @@ import { PortfolioStateShape, preparePortfolioKit, } from '../src/portfolio.exo.ts'; -import type { StatusFor } from '../src/type-guards.ts'; +import type { StatusFor } from '@agoric/portfolio-api'; import { PositionStateShape } from '../src/pos.exo.ts'; const { brand: USDC } = makeIssuerKit('USDC'); diff --git a/packages/portfolio-contract/test/rebalance.test.ts b/packages/portfolio-contract/test/rebalance.test.ts index c921ddf40a2..8efffb7e585 100644 --- a/packages/portfolio-contract/test/rebalance.test.ts +++ b/packages/portfolio-contract/test/rebalance.test.ts @@ -8,7 +8,7 @@ import type { YieldProtocol, } from '@agoric/portfolio-api/src/constants.js'; import { Far } from '@endo/marshal'; -import type { PoolKey } from '../src/type-guards.js'; +import type { PoolKey } from '@agoric/portfolio-api'; import type { AssetPlaceRef } from '../src/type-guards-steps.js'; import type { NetworkSpec, diff --git a/packages/portfolio-contract/test/supports.ts b/packages/portfolio-contract/test/supports.ts index 05a879e23ba..f5d026947c4 100644 --- a/packages/portfolio-contract/test/supports.ts +++ b/packages/portfolio-contract/test/supports.ts @@ -29,7 +29,7 @@ import { withAmountUtils } from '@agoric/zoe/tools/test-utils.js'; import { E } from '@endo/far'; import type { ExecutionContext } from 'ava'; import { encodeAbiParameters } from 'viem'; -import type { StatusFor } from '../src/type-guards.ts'; +import type { StatusFor } from '@agoric/portfolio-api'; import { gmpAddresses } from './mocks.ts'; export const makeIncomingEVMEvent = ({ diff --git a/packages/portfolio-contract/tools/graph-diagnose.ts b/packages/portfolio-contract/tools/graph-diagnose.ts index f0fadfd8431..188a68019fd 100644 --- a/packages/portfolio-contract/tools/graph-diagnose.ts +++ b/packages/portfolio-contract/tools/graph-diagnose.ts @@ -17,7 +17,7 @@ import { PoolPlaces, type PoolKey, type PoolPlaceInfo, -} from '../src/type-guards.js'; +} from '@agoric/portfolio-api'; import type { RebalanceGraph } from './network/buildGraph.js'; import type { NetworkSpec } from './network/network-spec.js'; import type { LpModel } from './plan-solve.js'; diff --git a/packages/portfolio-contract/tools/network/buildGraph.ts b/packages/portfolio-contract/tools/network/buildGraph.ts index ed8ea55877f..e837ee54bb2 100644 --- a/packages/portfolio-contract/tools/network/buildGraph.ts +++ b/packages/portfolio-contract/tools/network/buildGraph.ts @@ -4,7 +4,7 @@ import type { NatAmount, Amount } from '@agoric/ertp/src/types.js'; import { partialMap } from '@agoric/internal/src/js-utils.js'; import { AxelarChain } from '@agoric/portfolio-api/src/constants.js'; -import { PoolPlaces } from '../../src/type-guards.js'; +import { PoolPlaces } from '@agoric/portfolio-api'; import type { PoolKey } from '../../src/type-guards.js'; import type { AssetPlaceRef } from '../../src/type-guards-steps.js'; diff --git a/packages/portfolio-contract/tools/plan-rebalance.ts b/packages/portfolio-contract/tools/plan-rebalance.ts index 54cef9e5cdb..6d4c8ce0b04 100644 --- a/packages/portfolio-contract/tools/plan-rebalance.ts +++ b/packages/portfolio-contract/tools/plan-rebalance.ts @@ -1,6 +1,6 @@ import { AmountMath } from '@agoric/ertp'; import type { Brand, NatAmount } from '@agoric/ertp/src/types.js'; -import type { PoolKey } from '@aglocal/portfolio-contract/src/type-guards.js'; +import type { PoolKey } from '@agoric/portfolio-api'; import type { AssetPlaceRef } from '../src/type-guards-steps.ts'; /** diff --git a/packages/portfolio-contract/tools/plan-solve.ts b/packages/portfolio-contract/tools/plan-solve.ts index ff00870e748..681f22cedaa 100644 --- a/packages/portfolio-contract/tools/plan-solve.ts +++ b/packages/portfolio-contract/tools/plan-solve.ts @@ -18,8 +18,8 @@ import type { } from '@agoric/portfolio-api/src/constants.js'; import type { AssetPlaceRef, MovementDesc } from '../src/type-guards-steps.js'; -import { PoolPlaces } from '../src/type-guards.js'; -import type { PoolKey } from '../src/type-guards.js'; +import { PoolPlaces } from '@agoric/portfolio-api'; +import type { PoolKey } from '@agoric/portfolio-api'; import { preflightValidateNetworkPlan, formatInfeasibleDiagnostics, diff --git a/packages/portfolio-contract/tools/plan-transfers.ts b/packages/portfolio-contract/tools/plan-transfers.ts index 11b631a39e2..86bff2c2c0b 100644 --- a/packages/portfolio-contract/tools/plan-transfers.ts +++ b/packages/portfolio-contract/tools/plan-transfers.ts @@ -1,6 +1,6 @@ import { AmountMath } from '@agoric/ertp'; import type { Amount, Brand, NatAmount, NatValue } from '@agoric/ertp'; -import type { TargetAllocation } from '@aglocal/portfolio-contract/src/type-guards.js'; +import type { TargetAllocation } from '@agoric/portfolio-api'; import { NonNullish } from '@agoric/internal'; import type { YieldProtocol, diff --git a/packages/portfolio-contract/tools/portfolio-actors.ts b/packages/portfolio-contract/tools/portfolio-actors.ts index c008dc19e04..1815f4053d5 100644 --- a/packages/portfolio-contract/tools/portfolio-actors.ts +++ b/packages/portfolio-contract/tools/portfolio-actors.ts @@ -20,11 +20,11 @@ import { type start } from '@aglocal/portfolio-contract/src/portfolio.contract.j import { makePositionPath, portfolioIdOfPath, - type OfferArgsFor, - type ProposalType, + readPortfolioLatest, type StatusFor, type PoolKey, -} from '@aglocal/portfolio-contract/src/type-guards.js'; +} from '@agoric/portfolio-api'; +import type { OfferArgsFor, ProposalType } from '@aglocal/portfolio-contract/src/type-guards.js'; import type { WalletTool } from '@aglocal/portfolio-contract/tools/wallet-offer-tools.js'; import type { PortfolioPublicInvitationMaker, @@ -40,17 +40,45 @@ export const makePortfolioQuery = ( readPublished: VstorageKit['readPublished'], portfolioKey: `${string}.portfolios.portfolio${number}`, ) => { + const segments = portfolioKey.split('.'); + const portfolioSegment = segments.pop(); + portfolioSegment || assert.fail('missing portfolio segment'); + const portfoliosPathPrefix = segments.join('.'); + assert( + portfolioSegment.startsWith('portfolio'), + `invalid portfolio key ${portfolioKey}`, + ); + + const readPortfolioSnapshot = async () => + readPortfolioLatest({ + readLatest: path => readPublished(stripRoot(path)), + portfoliosPathPrefix, + portfolioKey: /** @type {`portfolio${number}`} */ ( + portfolioSegment + ), + includeSteps: false, + includePositions: true, + }); + const self = harden({ - getPortfolioStatus: () => - readPublished(portfolioKey) as Promise, + getPortfolioSnapshot: () => readPortfolioSnapshot(), + getPortfolioStatus: async () => { + const snapshot = await readPortfolioSnapshot(); + return snapshot.status; + }, getPositionPaths: async () => { - const { positionKeys } = await self.getPortfolioStatus(); + const snapshot = await readPortfolioSnapshot(); + const { positionKeys } = snapshot.status; return positionKeys.map(key => `${portfolioKey}.positions.${key}`); }, - getPositionStatus: (key: PoolKey) => - readPublished(`${portfolioKey}.positions.${key}`) as Promise< + getPositionStatus: async (key: PoolKey) => { + const snapshot = await readPortfolioSnapshot(); + const positionEntry = snapshot.positions?.[key]; + if (positionEntry?.status) return positionEntry.status; + return readPublished(`${portfolioKey}.positions.${key}`) as Promise< StatusFor['position'] - >, + >; + }, }); return self; }; @@ -91,6 +119,26 @@ export const makeTrader = ( const { brand: accessBrand } = wallet.getAssets().Access; const Access = AmountMath.make(accessBrand, 1n); + const getPortfolioPathOrFail = () => + portfolioPath || assert.fail('no portfolio'); + + const readPortfolioSnapshot = async () => { + const fullPath = getPortfolioPathOrFail(); + const segments = fullPath.split('.'); + const portfolioSegment = segments.pop(); + portfolioSegment || assert.fail('missing portfolio segment'); + const prefix = segments.join('.'); + return readPortfolioLatest({ + readLatest: path => readPublished(stripRoot(path)), + portfoliosPathPrefix: prefix, + portfolioKey: /** @type {`portfolio${number}`} */ ( + portfolioSegment + ), + includeSteps: false, + includePositions: true, + }); + }; + const self = harden({ /** * **Phase 1**: Opens a new portfolio with initial funding. @@ -218,14 +266,16 @@ export const makeTrader = ( }; return wallet.executeContinuingOffer({ id, invitationSpec, proposal }); }, - getPortfolioId: () => portfolioIdOfPath(stripRoot(self.getPortfolioPath())), - getPortfolioPath: () => portfolioPath || assert.fail('no portfolio'), - getPortfolioStatus: () => - readPublished(stripRoot(self.getPortfolioPath())) as Promise< - StatusFor['portfolio'] - >, + getPortfolioId: () => + portfolioIdOfPath(stripRoot(self.getPortfolioPath())), + getPortfolioPath: () => getPortfolioPathOrFail(), + getPortfolioStatus: async () => { + const snapshot = await readPortfolioSnapshot(); + return snapshot.status; + }, getPositionPaths: async () => { - const { positionKeys } = await self.getPortfolioStatus(); + const snapshot = await readPortfolioSnapshot(); + const { positionKeys } = snapshot.status; // XXX why do we have to add 'portfolios'? return positionKeys.map(key => @@ -235,18 +285,15 @@ export const makeTrader = ( ); }, netTransfersByPosition: async () => { - const paths = await self.getPositionPaths(); - const positionStatuses = await Promise.all( - paths.map( - path => readPublished(path) as Promise, - ), - ); - return fromEntries( - positionStatuses.map(info => [ - info.protocol, - AmountMath.subtract(info.totalIn, info.totalOut), - ]), + const snapshot = await readPortfolioSnapshot(); + const entries = Object.values(snapshot.positions || {}).flatMap( + position => { + if (!position?.status) return []; + const { totalIn, totalOut, protocol } = position.status; + return [[protocol, AmountMath.subtract(totalIn, totalOut)]]; + }, ); + return fromEntries(entries); }, }); return self; diff --git a/packages/portfolio-contract/tools/rebalance-grok.ts b/packages/portfolio-contract/tools/rebalance-grok.ts index c282a3326ce..92d3c317321 100644 --- a/packages/portfolio-contract/tools/rebalance-grok.ts +++ b/packages/portfolio-contract/tools/rebalance-grok.ts @@ -10,11 +10,8 @@ import { type MovementDesc, type SeatKeyword, } from '../src/type-guards-steps.js'; -import { - makeProposalShapes, - PoolPlaces, - type PoolKey, -} from '../src/type-guards.js'; +import { PoolPlaces, type PoolKey } from '@agoric/portfolio-api'; +import { makeProposalShapes } from '../src/type-guards.ts'; /** OCap exception: infrastructure to make up for lack of import x from 'foo.txt' */ export const importText = (specifier: string, base): Promise => diff --git a/services/ymax-planner/src/engine.ts b/services/ymax-planner/src/engine.ts index c6b0cbe8b0c..e3fdd996b74 100644 --- a/services/ymax-planner/src/engine.ts +++ b/services/ymax-planner/src/engine.ts @@ -26,17 +26,18 @@ import { type TxId, } from '@aglocal/portfolio-contract/src/resolver/types.ts'; import type { MovementDesc } from '@aglocal/portfolio-contract/src/type-guards-steps.js'; -import type { - FlowDetail, - PoolKey as InstrumentId, - StatusFor, -} from '@aglocal/portfolio-contract/src/type-guards.ts'; import { - flowIdFromKey, PoolPlaces, - portfolioIdFromKey, PortfolioStatusShapeExt, -} from '@aglocal/portfolio-contract/src/type-guards.ts'; + flowIdFromKey, + portfolioIdFromKey, + selectPendingFlows, + readPortfolioLatest, + type FlowDetail, + type InstrumentId, + type PortfolioKey, + type StatusFor, +} from '@agoric/portfolio-api'; import { PROD_NETWORK } from '@aglocal/portfolio-contract/tools/network/network.prod.js'; import type { GasEstimator } from '@aglocal/portfolio-contract/tools/plan-solve.ts'; import { @@ -392,16 +393,33 @@ export const processPortfolioEvents = async ( if (handledPortfolioKeys.has(portfolioKey)) return; handledPortfolioKeys.add(portfolioKey); const path = `${portfoliosPathPrefix}.${portfolioKey}`; - const readOpts = { minBlockHeight: eventRecord.blockHeight, retries: 4, }; + const readOpts = { minBlockHeight: eventRecord.blockHeight, retries: 4 }; await null; try { - const [statusCapdata, flowKeysResp] = await Promise.all([ - readStreamCellValue(vstorage, path, readOpts), - readStorageMeta(vstorage, `${path}.flows`, 'children', readOpts), - ]); - const status = marshaller.fromCapData(statusCapdata); + const snapshot = await readPortfolioLatest({ + readLatest: async readPath => { + const capData = await readStreamCellValue( + vstorage, + readPath, + readOpts, + ); + return marshaller.fromCapData(capData); + }, + listChildren: async childrenPath => { + const resp = await readStorageMeta( + vstorage, + childrenPath, + 'children', + readOpts, + ); + return resp.result.children; + }, + portfoliosPathPrefix, + portfolioKey: portfolioKey as PortfolioKey, + includeSteps: false, + }); + const { status, flows } = snapshot; mustMatch(status, PortfolioStatusShapeExt, path); - const flowKeys = new Set(flowKeysResp.result.children); const { depositAddress } = status; if (depositAddress) { @@ -413,7 +431,16 @@ export const processPortfolioEvents = async ( memory.snapshots ||= new Map(); const oldState = memory.snapshots.get(portfolioKey); const oldFingerprint = oldState?.fingerprint; - const fingerprint = fingerprintPortfolioState(status, flowKeys, { marshaller }); + const flowKeysWithNodes = new Set( + values(flows) + .filter(flowNode => flowNode.status) + .map(flowNode => flowNode.flowKey), + ); + const fingerprint = fingerprintPortfolioState( + status, + flowKeysWithNodes, + { marshaller }, + ); if (fingerprint === oldFingerprint) { assert(oldState); if (!oldState.repeats) console.warn(`⚠️ Ignoring unchanged ${path}`); @@ -428,12 +455,13 @@ export const processPortfolioEvents = async ( // acceptance of the first submission would invalidate the others as // stale, but we'll see them again when such acceptance prompts changes // to the portfolio status. - for (const [flowKey, flowDetail] of entries(status.flowsRunning || {})) { - // If vstorage has data for this flow then we've already responded. - if (flowKeys.has(flowKey)) continue; + const pendingFlows = selectPendingFlows(snapshot); + for (const { flowKey, detail } of pendingFlows) { + if (!detail) continue; await runWithFlowTrace( - portfolioKey, flowKey, - () => startFlow(status, portfolioKey, flowKey, flowDetail), + portfolioKey, + flowKey, + () => startFlow(status, portfolioKey, flowKey, detail), ); return; } diff --git a/services/ymax-planner/src/plan-deposit.ts b/services/ymax-planner/src/plan-deposit.ts index 0e5075c0e6e..cfec116b701 100644 --- a/services/ymax-planner/src/plan-deposit.ts +++ b/services/ymax-planner/src/plan-deposit.ts @@ -1,10 +1,12 @@ import { PoolPlaces, + enumeratePortfolioPlaces, type PoolKey, type PoolPlaceInfo, + type PortfolioPositionPlace, type StatusFor, type TargetAllocation, -} from '@aglocal/portfolio-contract/src/type-guards.js'; +} from '@agoric/portfolio-api'; import { AmountMath } from '@agoric/ertp/src/amountMath.js'; import type { Brand, NatAmount, NatValue } from '@agoric/ertp/src/types.js'; import { @@ -156,19 +158,17 @@ export const getCurrentBalances = async ( brand: Brand<'nat'>, powers: BalanceQueryPowers, ): Promise>> => { - const { positionKeys, accountIdByChain } = status; const { spectrumBlockchain, spectrumPools } = powers; + const { chainAccounts, positions } = enumeratePortfolioPlaces({ status }); const addressInfo = new Map(); const accountQueries = [] as AccountQueryDescriptor[]; const positionQueries = [] as PositionQueryDescriptor[]; const balances = new Map(); const errors = [] as Error[]; - for (const [chainName, accountId] of typedEntries( - accountIdByChain as Required, - )) { - const place = `@${chainName}` as AssetPlaceRef; + for (const { chainName, accountId, place } of chainAccounts) { balances.set(place, undefined); try { + accountId || Fail`Missing account ID for chain ${chainName}`; const addressParts = parseAccountId(accountId); addressInfo.set(chainName, addressParts); const { accountAddress: address } = addressParts; @@ -178,16 +178,19 @@ export const getCurrentBalances = async ( errors.push(Error(`Invalid CAIP-10 address for chain: ${chainName}`)); } } - for (const instrument of positionKeys) { - const place = instrument; + for (const position of positions) { + const { poolKey: instrument, chainName, accountId, pool, place } = position; balances.set(place, undefined); try { const poolPlaceInfo = + pool || getOwn(PoolPlaces, instrument) || Fail`Unknown instrument: ${instrument}`; - const { chainName, protocol } = poolPlaceInfo; + const { protocol } = poolPlaceInfo; + chainName || Fail`Missing chain for instrument ${instrument}`; const { namespace, accountAddress: address } = addressInfo.get(chainName) || + (accountId ? parseAccountId(accountId) : undefined) || Fail`No ${chainName} address for instrument ${instrument}`; if (namespace !== 'eip155') { // USDN Vaults are not "pools" and specifically are not in the Spectrum @@ -250,24 +253,28 @@ export const getCurrentBalances = async ( } // XXX Fallback during the transition to using only Spectrum GraphQL. const balanceEntries = await Promise.all( - positionKeys.map(async (posKey: PoolKey): Promise<[PoolKey, NatAmount]> => { - await null; - try { - const poolPlaceInfo = - getOwn(PoolPlaces, posKey) || Fail`Unknown PoolPlace`; - // TODO there should be a bulk query operation available now - const amountValue = await getCurrentBalance( - poolPlaceInfo, - accountIdByChain, - powers, - ); - return [posKey, AmountMath.make(brand, amountValue)]; - } catch (cause) { - errors.push(Error(`Could not get ${posKey} balance`, { cause })); - // @ts-expect-error - return [posKey, undefined]; - } - }), + positions.map( + async ({ + poolKey: posKey, + }: PortfolioPositionPlace): Promise<[PoolKey, NatAmount]> => { + await null; + try { + const poolPlaceInfo = + getOwn(PoolPlaces, posKey) || Fail`Unknown PoolPlace`; + // TODO there should be a bulk query operation available now + const amountValue = await getCurrentBalance( + poolPlaceInfo, + status.accountIdByChain, + powers, + ); + return [posKey, AmountMath.make(brand, amountValue)]; + } catch (cause) { + errors.push(Error(`Could not get ${posKey} balance`, { cause })); + // @ts-expect-error + return [posKey, undefined]; + } + }, + ), ); if (errors.length) { throw AggregateError(errors, 'Could not get balances'); diff --git a/services/ymax-planner/src/support.ts b/services/ymax-planner/src/support.ts index 781e14e3203..f576b6554e1 100644 --- a/services/ymax-planner/src/support.ts +++ b/services/ymax-planner/src/support.ts @@ -10,9 +10,9 @@ import { } from '@agoric/portfolio-api/src/constants.js'; import type { SupportedChain } from '@agoric/portfolio-api/src/constants.js'; import type { - PoolKey as InstrumentId, + InstrumentId, PoolPlaceInfo, -} from '@aglocal/portfolio-contract/src/type-guards.js'; +} from '@agoric/portfolio-api'; import { aaveRewardsControllerAddresses, compoundAddresses, diff --git a/services/ymax-planner/test/engine.test.ts b/services/ymax-planner/test/engine.test.ts index 0f00c5c8016..892488d93d3 100644 --- a/services/ymax-planner/test/engine.test.ts +++ b/services/ymax-planner/test/engine.test.ts @@ -5,7 +5,7 @@ import { Fail } from '@endo/errors'; import type { PortfolioPlanner } from '@aglocal/portfolio-contract/src/planner.exo.ts'; import type { MovementDesc } from '@aglocal/portfolio-contract/src/type-guards-steps.js'; -import type { StatusFor } from '@aglocal/portfolio-contract/src/type-guards.ts'; +import type { StatusFor } from '@agoric/portfolio-api'; import type { QueryChildrenMetaResponse, QueryDataMetaResponse,