diff --git a/packages/orchestration/src/chain-info.js b/packages/orchestration/src/chain-info.js index 3c268dad4f6..8e343c157b1 100644 --- a/packages/orchestration/src/chain-info.js +++ b/packages/orchestration/src/chain-info.js @@ -2,11 +2,9 @@ /** @file temporary static lookup of chain info */ -import { E } from '@endo/far'; +import { registerChain } from './utils/chainHub.js'; -/** - * @import {CosmosChainInfo, EthChainInfo} from './types.js'; - */ +/** @import {CosmosChainInfo, EthChainInfo} from './types.js'; */ /** @typedef {CosmosChainInfo | EthChainInfo} ChainInfo */ @@ -83,7 +81,7 @@ export const wellKnownChainInfo = stakingTokens: [{ denom: 'ustride' }], }, cosmos: { - chainId: 'cosmoshub-4', + chainId: 'cosmoslocal', connections: {}, icaEnabled: true, icqEnabled: true, @@ -101,7 +99,7 @@ export const wellKnownChainInfo = stakingTokens: [{ denom: 'utia' }], }, osmosis: { - chainId: 'osmosis-1', + chainId: 'osmosislocal', connections: {}, icaEnabled: true, icqEnabled: true, @@ -117,14 +115,8 @@ export const wellKnownChainInfo = * @param {(...messages: string[]) => void} log */ export const registerChainNamespace = async (agoricNamesAdmin, log) => { - const { nameAdmin } = await E(agoricNamesAdmin).provideChild('chain'); - - const registrationPromises = Object.entries(wellKnownChainInfo).map( - async ([name, info]) => { - log(`registering chain ${name}`); - return E(nameAdmin).update(name, info); - }, - ); - - await Promise.all(registrationPromises); + for await (const [name, info] of Object.entries(wellKnownChainInfo)) { + log(`registering agoricNames chain.${name}`); + await registerChain(agoricNamesAdmin, name, info); + } }; diff --git a/packages/orchestration/src/examples/sendAnywhere.contract.js b/packages/orchestration/src/examples/sendAnywhere.contract.js new file mode 100644 index 00000000000..ed49472cee2 --- /dev/null +++ b/packages/orchestration/src/examples/sendAnywhere.contract.js @@ -0,0 +1,168 @@ +import { makeDurableZone } from '@agoric/zone/durable.js'; +import { M, mustMatch } from '@endo/patterns'; +import { InvitationShape } from '@agoric/zoe/src/typeGuards.js'; +import { E } from '@endo/far'; +import { withdrawFromSeat } from '@agoric/zoe/src/contractSupport/zoeHelpers.js'; + +import { AmountShape } from '@agoric/ertp'; +import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; +import { CosmosChainInfoShape } from '../typeGuards.js'; +import { makeOrchestrationFacade } from '../facade.js'; +import { prepareLocalChainAccountKit } from '../exos/local-chain-account-kit.js'; +import { makeChainHub } from '../utils/chainHub.js'; + +const { entries } = Object; +const { Fail } = assert; + +/** + * @import {Baggage} from '@agoric/vat-data'; + * @import {CosmosChainInfo, IBCConnectionInfo} from '../cosmos-api'; + * @import {TimerService, TimerBrand} from '@agoric/time'; + * @import {LocalChain} from '@agoric/vats/src/localchain.js'; + * @import {OrchestrationService} from '../service.js'; + * @import {NameHub} from '@agoric/vats'; + * @import {Remote} from '@agoric/vow'; + */ + +/** + * @typedef {{ + * localchain: Remote; + * orchestrationService: Remote; + * storageNode: Remote; + * timerService: Remote; + * agoricNames: Remote; + * }} OrchestrationPowers + */ + +export const SingleAmountRecord = M.and( + M.recordOf(M.string(), AmountShape, { + numPropertiesLimit: 1, + }), + M.not(harden({})), +); + +/** + * @param {ZCF} zcf + * @param {OrchestrationPowers & { + * marshaller: Marshaller; + * }} privateArgs + * @param {Baggage} baggage + */ +export const start = async (zcf, privateArgs, baggage) => { + const zone = makeDurableZone(baggage); + + const chainHub = makeChainHub(privateArgs.agoricNames); + + // TODO once durability is settled, provide some helpers to reduce boilerplate + const { marshaller, ...orchPowers } = privateArgs; + const { makeRecorderKit } = prepareRecorderKitMakers(baggage, marshaller); + const makeLocalChainAccountKit = prepareLocalChainAccountKit( + zone, + makeRecorderKit, + zcf, + privateArgs.timerService, + chainHub, + ); + const { orchestrate } = makeOrchestrationFacade({ + zcf, + zone, + chainHub, + makeLocalChainAccountKit, + ...orchPowers, + }); + + let contractAccount; + + const findBrandInVBank = async brand => { + const assets = await E( + E(privateArgs.agoricNames).lookup('vbankAsset'), + ).values(); + const it = assets.find(a => a.brand === brand); + it || Fail`brand ${brand} not in agoricNames.vbankAsset`; + return it; + }; + + /** @type {OfferHandler} */ + const sendIt = orchestrate( + 'sendIt', + { zcf }, + // eslint-disable-next-line no-shadow -- this `zcf` is enclosed in a membrane + async (orch, { zcf }, seat, offerArgs) => { + mustMatch( + offerArgs, + harden({ chainName: M.scalar(), destAddr: M.string() }), + ); + const { chainName, destAddr } = offerArgs; + const { give } = seat.getProposal(); + const [[kw, amt]] = entries(give); + const { denom } = await findBrandInVBank(amt.brand); + const chain = await orch.getChain(chainName); + + // XXX ok to use a heap var crossing the membrane scope this way? + if (!contractAccount) { + const agoricChain = await orch.getChain('agoric'); + contractAccount = await agoricChain.makeAccount(); + } + + const info = await chain.getChainInfo(); + const { chainId } = info; + const { [kw]: pmtP } = await withdrawFromSeat(zcf, seat, give); + await E.when(pmtP, pmt => contractAccount.deposit(pmt, amt)); + await contractAccount.transfer( + { denom, value: amt.value }, + { + address: destAddr, + addressEncoding: 'bech32', + chainId, + }, + ); + }, + ); + + const publicFacet = zone.exo( + 'Send PF', + M.interface('Send PF', { + makeSendInvitation: M.callWhen().returns(InvitationShape), + }), + { + makeSendInvitation() { + return zcf.makeInvitation( + sendIt, + 'send', + undefined, + M.splitRecord({ give: SingleAmountRecord }), + ); + }, + }, + ); + + let nonce = 0n; + const ConnectionInfoShape = M.record(); // TODO + const creatorFacet = zone.exo( + 'Send CF', + M.interface('Send CF', { + addChain: M.callWhen(CosmosChainInfoShape, ConnectionInfoShape).returns( + M.scalar(), + ), + }), + { + /** + * @param {CosmosChainInfo} chainInfo + * @param {IBCConnectionInfo} connectionInfo + */ + async addChain(chainInfo, connectionInfo) { + const chainKey = `${chainInfo.chainId}-${(nonce += 1n)}`; + const agoricChainInfo = await chainHub.getChainInfo('agoric'); + chainHub.registerChain(chainKey, chainInfo); + chainHub.registerConnection( + agoricChainInfo.chainId, + chainInfo.chainId, + connectionInfo, + ); + return chainKey; + }, + }, + ); + + return { publicFacet, creatorFacet }; +}; diff --git a/packages/orchestration/src/examples/stakeBld.contract.js b/packages/orchestration/src/examples/stakeBld.contract.js index 410277d759d..2e9ab0199d0 100644 --- a/packages/orchestration/src/examples/stakeBld.contract.js +++ b/packages/orchestration/src/examples/stakeBld.contract.js @@ -10,11 +10,12 @@ import { E } from '@endo/far'; import { deeplyFulfilled } from '@endo/marshal'; import { M } from '@endo/patterns'; import { prepareLocalChainAccountKit } from '../exos/local-chain-account-kit.js'; +import { makeChainHub } from '../utils/chainHub.js'; /** * @import {NameHub} from '@agoric/vats'; * @import {Remote} from '@agoric/internal'; - * @import {TimerBrand, TimerService} from '@agoric/time'; + * @import {TimerService} from '@agoric/time'; * @import {LocalChain} from '@agoric/vats/src/localchain.js'; */ @@ -28,7 +29,6 @@ const trace = makeTracer('StakeBld'); * marshaller: Marshaller; * storageNode: StorageNode; * timerService: TimerService; - * timerBrand: TimerBrand; * }} privateArgs * @param {import('@agoric/vat-data').Baggage} baggage */ @@ -45,20 +45,12 @@ export const start = async (zcf, privateArgs, baggage) => { privateArgs.marshaller, ); - // FIXME in a second incarnation we can't make a remote call before defining all kinds - // UNTIL https://github.com/Agoric/agoric-sdk/issues/8879 - const agoricChainInfo = await E(privateArgs.agoricNames).lookup( - 'chain', - 'agoric', - ); - const makeLocalChainAccountKit = prepareLocalChainAccountKit( zone, makeRecorderKit, zcf, privateArgs.timerService, - privateArgs.timerBrand, - agoricChainInfo, + makeChainHub(privateArgs.agoricNames), ); async function makeLocalAccountKit() { diff --git a/packages/orchestration/src/examples/swapExample.contract.js b/packages/orchestration/src/examples/swapExample.contract.js index 448663f7eb1..34c0f83a16e 100644 --- a/packages/orchestration/src/examples/swapExample.contract.js +++ b/packages/orchestration/src/examples/swapExample.contract.js @@ -5,8 +5,11 @@ import { makeDurableZone } from '@agoric/zone/durable.js'; import { Far } from '@endo/far'; import { deeplyFulfilled } from '@endo/marshal'; import { M, objectMap } from '@endo/patterns'; +import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; import { makeOrchestrationFacade } from '../facade.js'; import { orcUtils } from '../utils/orc.js'; +import { makeChainHub } from '../utils/chainHub.js'; +import { prepareLocalChainAccountKit } from '../exos/local-chain-account-kit.js'; /** * @import {Orchestrator, IcaAccount, CosmosValidatorAddress} from '../types.js' @@ -25,6 +28,7 @@ export const meta = { localchain: M.remotable('localchain'), orchestrationService: M.or(M.remotable('orchestration'), null), storageNode: StorageNodeShape, + marshaller: M.remotable('marshaller'), timerService: M.or(TimerServiceShape, null), }, upgradability: 'canUpgrade', @@ -48,6 +52,7 @@ export const makeNatAmountShape = (brand, min) => * orchestrationService: Remote; * storageNode: Remote; * timerService: Remote; + * marshaller: Marshaller; * }} privateArgs * @param {Baggage} baggage */ @@ -62,16 +67,28 @@ export const start = async (zcf, privateArgs, baggage) => { orchestrationService, storageNode, timerService, + marshaller, } = privateArgs; + const chainHub = makeChainHub(agoricNames); + const { makeRecorderKit } = prepareRecorderKitMakers(baggage, marshaller); + const makeLocalChainAccountKit = prepareLocalChainAccountKit( + zone, + makeRecorderKit, + zcf, + timerService, + chainHub, + ); + const { orchestrate } = makeOrchestrationFacade({ - agoricNames, localchain, orchestrationService, storageNode, timerService, zcf, zone, + chainHub, + makeLocalChainAccountKit, }); /** deprecated historical example */ diff --git a/packages/orchestration/src/examples/unbondExample.contract.js b/packages/orchestration/src/examples/unbondExample.contract.js index 70ae1696114..6e0e67291cf 100644 --- a/packages/orchestration/src/examples/unbondExample.contract.js +++ b/packages/orchestration/src/examples/unbondExample.contract.js @@ -1,7 +1,10 @@ import { makeDurableZone } from '@agoric/zone/durable.js'; import { Far } from '@endo/far'; import { M } from '@endo/patterns'; +import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; import { makeOrchestrationFacade } from '../facade.js'; +import { makeChainHub } from '../utils/chainHub.js'; +import { prepareLocalChainAccountKit } from '../exos/local-chain-account-kit.js'; /** * @import {Orchestrator, IcaAccount, CosmosValidatorAddress} from '../types.js' @@ -20,6 +23,7 @@ import { makeOrchestrationFacade } from '../facade.js'; * localchain: Remote; * orchestrationService: Remote; * storageNode: Remote; + * marshaller: Marshaller; * timerService: Remote; * }} privateArgs * @param {Baggage} baggage @@ -30,18 +34,29 @@ export const start = async (zcf, privateArgs, baggage) => { localchain, orchestrationService, storageNode, + marshaller, timerService, } = privateArgs; const zone = makeDurableZone(baggage); + const chainHub = makeChainHub(agoricNames); + const { makeRecorderKit } = prepareRecorderKitMakers(baggage, marshaller); + const makeLocalChainAccountKit = prepareLocalChainAccountKit( + zone, + makeRecorderKit, + zcf, + privateArgs.timerService, + chainHub, + ); const { orchestrate } = makeOrchestrationFacade({ - agoricNames, localchain, orchestrationService, storageNode, timerService, zcf, zone, + chainHub: makeChainHub(agoricNames), + makeLocalChainAccountKit, }); /** @type {OfferHandler} */ diff --git a/packages/orchestration/src/exos/local-chain-account-kit.js b/packages/orchestration/src/exos/local-chain-account-kit.js index 4b16a61c9f1..02a68998031 100644 --- a/packages/orchestration/src/exos/local-chain-account-kit.js +++ b/packages/orchestration/src/exos/local-chain-account-kit.js @@ -20,7 +20,9 @@ import { dateInSeconds, makeTimestampHelper } from '../utils/time.js'; * @import {AmountArg, ChainAddress, DenomAmount, IBCMsgTransferOptions, CosmosChainInfo} from '@agoric/orchestration'; * @import {RecorderKit, MakeRecorderKit} from '@agoric/zoe/src/contractSupport/recorder.js'. * @import {Zone} from '@agoric/zone'; + * @import {Remote} from '@agoric/internal'; * @import {TimerService, TimerBrand} from '@agoric/time'; + * @import {ChainHub} from '../utils/chainHub.js'; */ const trace = makeTracer('LCAH'); @@ -43,7 +45,7 @@ const HolderI = M.interface('holder', { getPublicTopics: M.call().returns(TopicsRecordShape), delegate: M.call(M.string(), AmountShape).returns(M.promise()), undelegate: M.call(M.string(), AmountShape).returns(M.promise()), - deposit: M.callWhen(PaymentShape).returns(AmountShape), + deposit: M.callWhen(PaymentShape).optional(AmountShape).returns(AmountShape), withdraw: M.callWhen(AmountShape).returns(PaymentShape), transfer: M.call(AmountArgShape, ChainAddressShape) .optional(IBCTransferOptionsShape) @@ -61,22 +63,19 @@ const PUBLIC_TOPICS = { * @param {Zone} zone * @param {MakeRecorderKit} makeRecorderKit * @param {ZCF} zcf - * @param {TimerService} timerService - * @param {TimerBrand} timerBrand - * @param {CosmosChainInfo} agoricChainInfo + * @param {Remote} timerService + * @param {ChainHub} chainHub */ export const prepareLocalChainAccountKit = ( zone, makeRecorderKit, zcf, timerService, - timerBrand, - agoricChainInfo, + chainHub, ) => { - const timestampHelper = makeTimestampHelper(timerService, timerBrand); - /** - * Make an object wrapping an LCA with Zoe interfaces. - */ + const timestampHelper = makeTimestampHelper(timerService); + // TODO: rename to makeLocalOrchestrationAccount or the like to distinguish from lca + /** Make an object wrapping an LCA with Zoe interfaces. */ const makeLocalChainAccountKit = zone.exoClassKit( 'LCA Kit', { @@ -220,9 +219,7 @@ export const prepareLocalChainAccountKit = ( // @ts-expect-error subtype return E(this.state.account).executeTx(messages); }, - /** - * @returns {ChainAddress['address']} - */ + /** @returns {ChainAddress['address']} */ getAddress() { return NonNullish(this.state.address, 'Chain address not available.'); }, @@ -240,12 +237,12 @@ export const prepareLocalChainAccountKit = ( // TODO #9211 lookup denom from brand if ('brand' in amount) throw Fail`ERTP Amounts not yet supported`; - destination.chainId in agoricChainInfo.connections || - Fail`Unknown chain ${destination.chainId}`; - // TODO #8879 chainInfo and #9063 well-known chains - const { transferChannel } = - agoricChainInfo.connections[destination.chainId]; + const agoricChainInfo = await chainHub.getChainInfo('agoric'); + const { transferChannel } = await chainHub.getConnectionInfo( + agoricChainInfo.chainId, + destination.chainId, + ); await null; // set a `timeoutTimestamp` if caller does not supply either `timeoutHeight` or `timeoutTimestamp` diff --git a/packages/orchestration/src/facade.js b/packages/orchestration/src/facade.js index 1f73ae93120..7ea851128c6 100644 --- a/packages/orchestration/src/facade.js +++ b/packages/orchestration/src/facade.js @@ -1,13 +1,9 @@ /** @file Orchestration service */ -import { makeScalarBigMapStore } from '@agoric/vat-data'; import { E } from '@endo/far'; -import { M } from '@endo/patterns'; import { prepareCosmosOrchestrationAccount } from './exos/cosmosOrchestrationAccount.js'; -import { CosmosChainInfoShape } from './typeGuards.js'; /** - * @import {NameHub} from '@agoric/vats'; * @import {Zone} from '@agoric/zone'; * @import {TimerService} from '@agoric/time'; * @import {LocalChain} from '@agoric/vats/src/localchain.js'; @@ -19,10 +15,6 @@ import { CosmosChainInfoShape } from './typeGuards.js'; /** @type {any} */ const anyVal = null; -// TODO define key hierarchy in shared constants -/** agoricNames key for ChainInfo hub */ -export const CHAIN_KEY = 'chain'; - // FIXME look up real values // UNTIL https://github.com/Agoric/agoric-sdk/issues/9063 const mockLocalChainInfo = { @@ -37,9 +29,12 @@ const mockLocalChainInfo = { /** * @param {Remote} localchain + * @param {ReturnType< + * typeof import('./exos/local-chain-account-kit.js').prepareLocalChainAccountKit + * >} makeLocalChainAccountKit * @returns {Chain} */ -const makeLocalChainFacade = localchain => { +const makeLocalChainFacade = (localchain, makeLocalChainAccountKit) => { return { /** @returns {Promise} */ async getChainInfo() { @@ -47,7 +42,14 @@ const makeLocalChainFacade = localchain => { }, async makeAccount() { - const account = await E(localchain).makeAccount(); + const lcaP = E(localchain).makeAccount(); + const [lca, address] = await Promise.all([lcaP, E(lcaP).getAddress()]); + const { holder: account } = makeLocalChainAccountKit({ + account: lca, + address, + // @ts-expect-error TODO: Remote + storageNode: null, + }); return { async deposit(payment) { @@ -70,7 +72,7 @@ const makeLocalChainFacade = localchain => { ? [/** @type {any} */ (null), denomArg] : [denomArg, 'FIXME']; - const natAmount = await account.getBalance(brand); + const natAmount = await E(lca).getBalance(brand); return harden({ denom, value: natAmount.value }); }, getBalances() { @@ -81,8 +83,8 @@ const makeLocalChainFacade = localchain => { console.log('send got', toAccount, amount); }, async transfer(amount, destination, opts) { - // FIXME implement console.log('transfer got', amount, destination, opts); + return account.transfer(amount, destination, opts); }, transferSteps(amount, msg) { console.log('transferSteps got', amount, msg); @@ -157,7 +159,10 @@ const makeRemoteChainFacade = ( * storageNode: Remote; * orchestrationService: Remote; * localchain: Remote; - * agoricNames: Remote; + * chainHub: import('./utils/chainHub.js').ChainHub; + * makeLocalChainAccountKit: ReturnType< + * typeof import('./exos/local-chain-account-kit.js').prepareLocalChainAccountKit + * >; * }} powers */ export const makeOrchestrationFacade = ({ @@ -167,7 +172,8 @@ export const makeOrchestrationFacade = ({ storageNode, orchestrationService, localchain, - agoricNames, + chainHub, + makeLocalChainAccountKit, }) => { console.log('makeOrchestrationFacade got', { zone, @@ -177,43 +183,7 @@ export const makeOrchestrationFacade = ({ orchestrationService, }); - const chainInfos = makeScalarBigMapStore('chainInfos', { - keyShape: M.string(), - valueShape: CosmosChainInfoShape, - }); - - /** - * @param {string} name - * @returns {Promise} - */ - const getChainInfo = async name => { - // Either from registerChain or memoized remote lookup() - if (chainInfos.has(name)) { - return chainInfos.get(name); - } - - const chainInfo = await E(agoricNames).lookup(CHAIN_KEY, name); - assert(chainInfo, `unknown chain ${name}`); - chainInfos.init(name, chainInfo); - return chainInfo; - }; - return { - /** - * Register a new chain in a heap store. The name will override a name in - * well known chain names. - * - * This registration will not surve a reincarnation of the vat so if the - * chain is not yet in the well known names at that point, it will have to - * be registered again. In an unchanged contract `start` the call will - * happen again naturally. - * - * @param {string} name - * @param {ChainInfo} chainInfo - */ - registerChain(name, chainInfo) { - chainInfos.init(name, chainInfo); - }, /** * @template Context * @template {any[]} Args @@ -228,10 +198,10 @@ export const makeOrchestrationFacade = ({ const orc = { async getChain(name) { if (name === 'agoric') { - return makeLocalChainFacade(localchain); + return makeLocalChainFacade(localchain, makeLocalChainAccountKit); } - const chainInfo = await getChainInfo(name); + const chainInfo = await chainHub.getChainInfo(name); return makeRemoteChainFacade(chainInfo, { orchestration: orchestrationService, diff --git a/packages/orchestration/src/proposals/start-stakeBld.js b/packages/orchestration/src/proposals/start-stakeBld.js index 86ce07079a5..1893874f5f9 100644 --- a/packages/orchestration/src/proposals/start-stakeBld.js +++ b/packages/orchestration/src/proposals/start-stakeBld.js @@ -48,10 +48,9 @@ export const startStakeBld = async ({ // NB: committee must only publish what it intended to be public const marshaller = await E(board).getPublishingMarshaller(); - const [agoricNames, timerService, timerBrand] = await Promise.all([ + const [agoricNames, timerService] = await Promise.all([ agoricNamesP, chainTimerServiceP, - chainTimerServiceP.then(ts => E(ts).getTimerBrand()), ]); /** @@ -69,7 +68,6 @@ export const startStakeBld = async ({ agoricNames, localchain: await localchain, timerService, - timerBrand, storageNode, marshaller, }, diff --git a/packages/orchestration/src/typeGuards.js b/packages/orchestration/src/typeGuards.js index c67b08fba39..d55a60230a9 100644 --- a/packages/orchestration/src/typeGuards.js +++ b/packages/orchestration/src/typeGuards.js @@ -42,13 +42,43 @@ export const IBCTransferOptionsShape = M.splitRecord( }, ); +export const IBCChannelIDShape = M.string(); +export const IBCChannelInfoShape = M.splitRecord({ + portId: M.string(), + channelId: IBCChannelIDShape, + counterPartyPortId: M.string(), + counterPartyChannelId: IBCChannelIDShape, + ordering: M.scalar(), // XXX + state: M.scalar(), // XXX + version: M.string(), +}); +export const IBCConnectionIDShape = M.string(); +export const IBCConnectionInfoShape = M.splitRecord({ + id: IBCConnectionIDShape, + client_id: M.string(), + state: M.scalar(), // XXX STATE_OPEN or... + counterparty: { + client_id: M.string(), + connection_id: IBCConnectionIDShape, + prefix: { + key_prefix: M.string(), + }, + }, + versions: M.arrayOf({ + identifier: M.string(), + features: M.arrayOf(M.string()), + }), + delay_period: M.nat(), + transferChannel: IBCChannelInfoShape, +}); + export const CosmosChainInfoShape = M.splitRecord( { chainId: M.string(), connections: M.record(), - stakingTokens: M.arrayOf({ denom: M.string() }), }, { + stakingTokens: M.arrayOf({ denom: M.string() }), icaEnabled: M.boolean(), icqEnabled: M.boolean(), pfmEnabled: M.boolean(), diff --git a/packages/orchestration/src/utils/chainHub.js b/packages/orchestration/src/utils/chainHub.js new file mode 100644 index 00000000000..fda7a005254 --- /dev/null +++ b/packages/orchestration/src/utils/chainHub.js @@ -0,0 +1,136 @@ +import { E } from '@endo/far'; +import { M, mustMatch } from '@endo/patterns'; +import { makeHeapZone } from '@agoric/zone'; +import { CosmosChainInfoShape, IBCConnectionInfoShape } from '../typeGuards.js'; + +/** + * @import {NameHub} from '@agoric/vats'; + * @import {CosmosChainInfo, IBCConnectionInfo} from '../cosmos-api.js'; + * @import {Remote} from '@agoric/internal'; + * @import {Zone} from '@agoric/zone'; + */ + +/** agoricNames key for ChainInfo hub */ +export const CHAIN_KEY = 'chain'; +/** namehub for connection info */ +export const CONNECTIONS_KEY = 'chainConnection'; + +/** + * The entries of the top-level namehubs in agoricNames are reflected to + * vstorage. But only the top level. So we combine the 2 chain ids into 1 key. + * Connections are directionless, so we sort the ids. + * + * @param {string} chainId1 + * @param {string} chainId2 + */ +export const connectionKey = (chainId1, chainId2) => + JSON.stringify([chainId1, chainId2].sort()); + +/** + * @param {Remote} agoricNames + * @param {Zone} [zone] + */ +export const makeChainHub = (agoricNames, zone = makeHeapZone()) => { + /** @type {MapStore} */ + const chainInfos = zone.mapStore('chainInfos', { + keyShape: M.string(), + valueShape: CosmosChainInfoShape, + }); + /** @type {MapStore} */ + const connectionInfos = zone.mapStore('connectionInfos', { + keyShape: M.string(), + valueShape: IBCConnectionInfoShape, + }); + + const chainHub = harden({ + /** + * Register a new chain. The name will override a name in well known chain + * names. + * + * If a durable zone was not provided, registration will not survive a + * reincarnation of the vat. Then if the chain is not yet in the well known + * names at that point, it will have to be registered again. In an unchanged + * contract `start` the call will happen again naturally. + * + * @param {string} name + * @param {CosmosChainInfo} chainInfo + */ + registerChain(name, chainInfo) { + chainInfos.init(name, chainInfo); + }, + /** + * @param {string} chainName + * @returns {Promise} + */ + async getChainInfo(chainName) { + // Either from registerChain or memoized remote lookup() + if (chainInfos.has(chainName)) { + return chainInfos.get(chainName); + } + + const chainInfo = await E(agoricNames) + .lookup(CHAIN_KEY, chainName) + .catch(_cause => { + throw assert.error(`chain not found:${chainName}`); + }); + chainInfos.init(chainName, chainInfo); + return chainInfo; + }, + /** + * @param {string} srcChainId + * @param {string} destChainId + * @param {IBCConnectionInfo} connectionInfo + */ + registerConnection(srcChainId, destChainId, connectionInfo) { + const key = connectionKey(srcChainId, destChainId); + connectionInfos.init(key, connectionInfo); + }, + + /** + * @param {string} srcChainId + * @param {string} destChainId + * @returns {Promise} + */ + async getConnectionInfo(srcChainId, destChainId) { + const key = connectionKey(srcChainId, destChainId); + if (connectionInfos.has(key)) { + return connectionInfos.get(key); + } + + const connectionInfo = await E(agoricNames) + .lookup(CONNECTIONS_KEY, key) + .catch(_cause => { + throw assert.error( + `connection not found: ${srcChainId}<->${destChainId}`, + ); + }); + connectionInfos.init(key, connectionInfo); + return connectionInfo; + }, + }); + + return chainHub; +}; +/** @typedef {ReturnType} ChainHub */ + +/** + * @param {ERef} agoricNamesAdmin + * @param {string} name + * @param {CosmosChainInfo} chainInfo + */ +export const registerChain = async (agoricNamesAdmin, name, chainInfo) => { + const { nameAdmin } = await E(agoricNamesAdmin).provideChild('chain'); + const { nameAdmin: connAdmin } = + await E(agoricNamesAdmin).provideChild('chainConnection'); + + mustMatch(chainInfo, CosmosChainInfoShape); + // XXX chainInfo.connections is redundant here. + await E(nameAdmin).update(name, chainInfo); + + for await (const [destChainId, connInfo] of Object.entries( + chainInfo.connections, + )) { + const key = connectionKey(chainInfo.chainId, destChainId); + await E(connAdmin).update(key, connInfo); + } +}; diff --git a/packages/orchestration/src/utils/time.js b/packages/orchestration/src/utils/time.js index ddac08af371..975069f4efa 100644 --- a/packages/orchestration/src/utils/time.js +++ b/packages/orchestration/src/utils/time.js @@ -3,17 +3,22 @@ import { TimeMath } from '@agoric/time'; /** * @import {RelativeTimeRecord, TimerBrand, TimerService} from '@agoric/time'; - * @import {MsgTransfer} from '@agoric/cosmic-proto/ibc/applications/transfer/v1/tx.js'; + * @import {Remote} from '@agoric/internal'; */ export const SECONDS_PER_MINUTE = 60n; export const NANOSECONDS_PER_SECOND = 1_000_000_000n; -/** - * @param {TimerService} timer - * @param {TimerBrand} timerBrand - */ -export function makeTimestampHelper(timer, timerBrand) { +/** @param {Remote} timer */ +export function makeTimestampHelper(timer) { + /** @type {TimerBrand | undefined} */ + let brandCache; + const getBrand = async () => { + if (brandCache) return brandCache; + brandCache = await E(timer).getTimerBrand(); + return brandCache; + }; + return harden({ /** * Takes the current time from ChainTimerService and adds a relative time to @@ -28,7 +33,10 @@ export function makeTimestampHelper(timer, timerBrand) { const currentTime = await E(timer).getCurrentTimestamp(); const timeout = relativeTime || - TimeMath.coerceRelativeTimeRecord(SECONDS_PER_MINUTE * 5n, timerBrand); + TimeMath.coerceRelativeTimeRecord( + SECONDS_PER_MINUTE * 5n, + await getBrand(), + ); return ( TimeMath.addAbsRel(currentTime, timeout).absValue * NANOSECONDS_PER_SECOND diff --git a/packages/orchestration/test/examples/sendAnywhere.test.ts b/packages/orchestration/test/examples/sendAnywhere.test.ts new file mode 100644 index 00000000000..9b09ec50f8d --- /dev/null +++ b/packages/orchestration/test/examples/sendAnywhere.test.ts @@ -0,0 +1,234 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; +import { E } from '@endo/far'; +import path from 'path'; + +import { mustMatch } from '@endo/patterns'; +import { makeIssuerKit } from '@agoric/ertp'; +import { CosmosChainInfo, IBCConnectionInfo } from '../../src/cosmos-api.js'; +import { commonSetup } from '../supports.js'; +import { SingleAmountRecord } from '../../src/examples/sendAnywhere.contract.js'; +import { registerChain } from '../../src/utils/chainHub.js'; + +const dirname = path.dirname(new URL(import.meta.url).pathname); + +const contractName = 'sendAnywhere'; +const contractFile = `${dirname}/../../src/examples/${contractName}.contract.js`; +type StartFn = + typeof import('../../src/examples/sendAnywhere.contract.js').start; + +const chainInfoDefaults = { + connections: {}, + allowedMessages: [], + allowedQueries: [], + ibcHooksEnabled: false, + icaEnabled: false, + icqEnabled: false, + pfmEnabled: false, +}; + +const connectionDefaults = { + versions: [ + { + identifier: '1', + features: ['ORDER_ORDERED', 'ORDER_UNORDERED'], + }, + ], + delay_period: 0n, +}; + +const txChannelDefaults = { + counterPartyPortId: 'transfer', + version: 'ics20-1', + portId: 'transfer', + ordering: 1, // ORDER_UNORDERED + state: 3, // STATE_OPEN +}; + +test('single amount proposal shape (keyword record)', async t => { + const { brand } = makeIssuerKit('IST'); + const amt = harden({ brand, value: 1n }); + const cases = harden({ + good: [{ Kw: amt }], + bad: [ + { give: { Kw1: amt, Kw2: amt }, msg: /more than 1/ }, + { give: {}, msg: /fail negated pattern: {}/ }, + { give: { Kw: 123 }, msg: /Must be a copyRecord/ }, + { give: { Kw: { brand: 1, value: 1n } }, msg: /Must be a remotable/ }, + ], + }); + for (const give of cases.good) { + t.notThrows(() => mustMatch(give, SingleAmountRecord)); + } + for (const { give, msg } of cases.bad) { + t.throws(() => mustMatch(give, SingleAmountRecord), { + message: msg, + }); + } +}); + +test('send using arbitrary chain info', async t => { + t.log('bootstrap, orchestration core-eval'); + const { + bootstrap, + commonPrivateArgs, + brands: { ist }, + utils: { inspectLocalBridge, pourPayment }, + } = await commonSetup(t); + + const { zoe, bundleAndInstall } = await setUpZoeForTest(); + + t.log('contract coreEval', contractName); + + const installation: Installation = + await bundleAndInstall(contractFile); + + const storageNode = await E(bootstrap.storage.rootNode).makeChildNode( + contractName, + ); + const sendKit = await E(zoe).startInstance( + installation, + { Stable: ist.issuer }, + {}, + { ...commonPrivateArgs, storageNode }, + ); + + const hotChainInfo = harden({ + chainId: 'hot-new-chain-0', + stakingTokens: [{ denom: 'uhot' }], + ...chainInfoDefaults, + }) as CosmosChainInfo; + t.log('admin adds chain using creatorFacet', hotChainInfo.chainId); + const agoricToHotConnection = { + ...connectionDefaults, + id: 'connection-1', + client_id: '07-tendermint-1', + state: 3, // STATE_OPEN + counterparty: { + client_id: '07-tendermint-2109', + connection_id: 'connection-1649', + prefix: { + key_prefix: 'aWJj', + }, + }, + transferChannel: { + counterPartyChannelId: 'channel-1', + channelId: 'channel-0', + ...txChannelDefaults, + }, + } as IBCConnectionInfo; + const chainName = await E(sendKit.creatorFacet).addChain( + hotChainInfo, + agoricToHotConnection, + ); + + t.log('client uses contract to send to hot new chain'); + { + const publicFacet = await E(zoe).getPublicFacet(sendKit.instance); + const inv = E(publicFacet).makeSendInvitation(); + const amt = await E(zoe).getInvitationDetails(inv); + t.is(amt.description, 'send'); + + const anAmt = ist.units(3.5); + const Send = await pourPayment(anAmt); + const userSeat = await E(zoe).offer( + inv, + { give: { Send: anAmt } }, + { Send }, + { destAddr: 'hot1destAddr', chainName }, + ); + await E(userSeat).getOfferResult(); + + const history = inspectLocalBridge(); + t.like(history, [ + { type: 'VLOCALCHAIN_ALLOCATE_ADDRESS' }, + { type: 'VLOCALCHAIN_EXECUTE_TX' }, + ]); + const [_alloc, { messages, address: execAddr }] = history; + t.is(messages.length, 1); + const [txfr] = messages; + t.log('local bridge', txfr); + t.like(txfr, { + '@type': '/ibc.applications.transfer.v1.MsgTransfer', + receiver: 'hot1destAddr', + sender: execAddr, + sourceChannel: 'channel-0', + sourcePort: 'transfer', + token: { amount: '3500000', denom: 'uist' }, + }); + } + + t.log('well-known chains such as cosmos work the same way'); + { + const anAmt = ist.units(1.25); + const Send = await pourPayment(anAmt); + const publicFacet = await E(zoe).getPublicFacet(sendKit.instance); + const inv = E(publicFacet).makeSendInvitation(); + const userSeat = await E(zoe).offer( + inv, + { give: { Send: anAmt } }, + { Send }, + { destAddr: 'cosmos1destAddr', chainName: 'cosmos' }, + ); + await E(userSeat).getOfferResult(); + const history = inspectLocalBridge(); + const { messages, address: execAddr } = history.at(-1); + t.is(messages.length, 1); + const [txfr] = messages; + t.log('local bridge', txfr); + t.like(txfr, { + '@type': '/ibc.applications.transfer.v1.MsgTransfer', + receiver: 'cosmos1destAddr', + sender: execAddr, + sourceChannel: 'channel-1', + token: { amount: '1250000', denom: 'uist' }, + }); + } + + t.log('hot chain is endorsed by chain governance'); + const { agoricNamesAdmin } = bootstrap; + await registerChain( + agoricNamesAdmin, + 'hot', + harden({ + ...hotChainInfo, + connections: { agoriclocal: agoricToHotConnection }, + }), + ); + + t.log('another contract uses the now well-known hot chain'); + const orchKit = await E(zoe).startInstance( + installation, + { Stable: ist.issuer }, + {}, + { ...commonPrivateArgs, storageNode }, + ); + + t.log('client can send to hot chain without admin action'); + { + const anAmt = ist.units(4.25); + const Send = await pourPayment(anAmt); + const publicFacet = await E(zoe).getPublicFacet(orchKit.instance); + const inv = E(publicFacet).makeSendInvitation(); + const userSeat = await E(zoe).offer( + inv, + { give: { Send: anAmt } }, + { Send }, + { destAddr: 'hot1destAddr', chainName: 'hot' }, + ); + await E(userSeat).getOfferResult(); + const history = inspectLocalBridge(); + const { messages, address: execAddr } = history.at(-1); + t.is(messages.length, 1); + const [txfr] = messages; + t.log('local bridge', txfr); + t.like(txfr, { + '@type': '/ibc.applications.transfer.v1.MsgTransfer', + receiver: 'hot1destAddr', + sender: execAddr, + sourceChannel: 'channel-0', + token: { amount: '4250000', denom: 'uist' }, + }); + } +}); diff --git a/packages/orchestration/test/examples/stake-bld.contract.test.ts b/packages/orchestration/test/examples/stake-bld.contract.test.ts index d9b75a2d05e..2b1ed79acd5 100644 --- a/packages/orchestration/test/examples/stake-bld.contract.test.ts +++ b/packages/orchestration/test/examples/stake-bld.contract.test.ts @@ -34,7 +34,6 @@ const startContract = async ({ marshaller, storageNode: storage.rootNode, timerService: timer, - timerBrand: timer.getTimerBrand(), }, ); return { publicFacet, zoe }; diff --git a/packages/orchestration/test/exos/local-chain-account-kit.test.ts b/packages/orchestration/test/exos/local-chain-account-kit.test.ts index 06e6b4469a3..7dc5819c644 100644 --- a/packages/orchestration/test/exos/local-chain-account-kit.test.ts +++ b/packages/orchestration/test/exos/local-chain-account-kit.test.ts @@ -7,9 +7,7 @@ import { commonSetup } from '../supports.js'; import { prepareLocalChainAccountKit } from '../../src/exos/local-chain-account-kit.js'; import { ChainAddress } from '../../src/orchestration-api.js'; import { NANOSECONDS_PER_SECOND } from '../../src/utils/time.js'; -import { wellKnownChainInfo } from '../../src/chain-info.js'; - -const agoricChainInfo = wellKnownChainInfo.agoric; +import { makeChainHub } from '../../src/utils/chainHub.js'; test('deposit, withdraw', async t => { const { bootstrap, brands, utils } = await commonSetup(t); @@ -31,8 +29,7 @@ test('deposit, withdraw', async t => { // @ts-expect-error mocked zcf. use `stake-bld.contract.test.ts` to test LCA with offer Far('MockZCF', {}), timer, - timer.getTimerBrand(), - agoricChainInfo, + makeChainHub(bootstrap.agoricNames), ); t.log('request account from vat-localchain'); @@ -96,8 +93,7 @@ test('delegate, undelegate', async t => { // @ts-expect-error mocked zcf. use `stake-bld.contract.test.ts` to test LCA with offer Far('MockZCF', {}), timer, - timer.getTimerBrand(), - agoricChainInfo, + makeChainHub(bootstrap.agoricNames), ); t.log('request account from vat-localchain'); @@ -144,8 +140,7 @@ test('transfer', async t => { // @ts-expect-error mocked zcf. use `stake-bld.contract.test.ts` to test LCA with offer Far('MockZCF', {}), timer, - timer.getTimerBrand(), - agoricChainInfo, + makeChainHub(bootstrap.agoricNames), ); t.log('request account from vat-localchain'); @@ -201,9 +196,7 @@ test('transfer', async t => { }; await t.throwsAsync( () => E(account).transfer({ denom: 'ubld', value: 1n }, unknownDestination), - { - message: /Unknown chain "fakenet"/, - }, + { message: /connection not found: agoriclocal<->fakenet/ }, 'cannot create transfer msg with unknown chainId', ); diff --git a/packages/orchestration/test/facade.test.ts b/packages/orchestration/test/facade.test.ts index 0057a932502..3d637205a91 100644 --- a/packages/orchestration/test/facade.test.ts +++ b/packages/orchestration/test/facade.test.ts @@ -5,6 +5,7 @@ import type { CosmosChainInfo } from '../src/cosmos-api.js'; import { makeOrchestrationFacade } from '../src/facade.js'; import type { Chain } from '../src/orchestration-api.js'; import { commonSetup } from './supports.js'; +import { makeChainHub } from '../src/utils/chainHub.js'; const test = anyTest; @@ -18,21 +19,26 @@ export const mockChainInfo: CosmosChainInfo = harden({ stakingTokens: [{ denom: 'umock' }], }); +const makeLocalChainAccountKit = () => assert.fail(`not used`); + test('chain info', async t => { const { bootstrap, facadeServices } = await commonSetup(t); const zone = bootstrap.rootZone; const { zcf } = await setupZCFTest(); + const chainHub = makeChainHub(facadeServices.agoricNames); - const { registerChain, orchestrate } = makeOrchestrationFacade({ + const { orchestrate } = makeOrchestrationFacade({ ...facadeServices, storageNode: bootstrap.storage.rootNode, zcf, zone, + chainHub, + makeLocalChainAccountKit, }); - registerChain('mock', mockChainInfo); + chainHub.registerChain('mock', mockChainInfo); const handle = orchestrate('mock', {}, async orc => { return orc.getChain('mock'); @@ -42,37 +48,4 @@ test('chain info', async t => { t.deepEqual(await result.getChainInfo(), mockChainInfo); }); -test('contract upgrade', async t => { - const { bootstrap, facadeServices } = await commonSetup(t); - - const zone = bootstrap.rootZone; - - const { zcf } = await setupZCFTest(); - - // Register once - { - const { registerChain } = makeOrchestrationFacade({ - ...facadeServices, - storageNode: bootstrap.storage.rootNode, - zcf, - zone, - }); - registerChain('mock', mockChainInfo); - - // cannot register again in this incarnation - t.throws(() => registerChain('mock', mockChainInfo), { - message: 'key "mock" already registered in collection "chainInfos"', - }); - } - - // Simulate running again in a new incarnation with the same zone - { - const { registerChain } = makeOrchestrationFacade({ - ...facadeServices, - storageNode: bootstrap.storage.rootNode, - zcf, - zone, - }); - registerChain('mock', mockChainInfo); - } -}); +test.todo('contract upgrade'); diff --git a/packages/orchestration/test/supports.ts b/packages/orchestration/test/supports.ts index cf666748556..4d4a7705cdc 100644 --- a/packages/orchestration/test/supports.ts +++ b/packages/orchestration/test/supports.ts @@ -19,12 +19,7 @@ import { makeNameHubKit } from '@agoric/vats'; import { makeWellKnownSpaces } from '@agoric/vats/src/core/utils.js'; import { fakeNetworkEchoStuff } from './network-fakes.js'; import { prepareOrchestrationTools } from '../src/service.js'; -import { CHAIN_KEY } from '../src/facade.js'; -import type { CosmosChainInfo } from '../src/cosmos-api.js'; -import { - registerChainNamespace, - wellKnownChainInfo, -} from '../src/chain-info.js'; +import { registerChainNamespace } from '../src/chain-info.js'; export { makeFakeLocalchainBridge, @@ -38,9 +33,12 @@ export const commonSetup = async t => { // To test durability in unit tests, test a particular entity with `relaxDurabilityRules: false`. // To test durability integrating multiple vats, use a RunUtils/bootstrap test. const rootZone = makeHeapZone(); + + const { nameHub: agoricNames, nameAdmin: agoricNamesAdmin } = + makeNameHubKit(); + const bld = withAmountUtils(makeIssuerKit('BLD')); const ist = withAmountUtils(makeIssuerKit('IST')); - const { bankManager, pourPayment } = await makeFakeBankManagerKit(); await E(bankManager).addAsset('ubld', 'BLD', 'Staking Token', bld.issuerKit); await E(bankManager).addAsset( @@ -49,6 +47,23 @@ export const commonSetup = async t => { 'Inter Stable Token', ist.issuerKit, ); + // These mints no longer stay in sync with bankManager. + // Use pourPayment() for IST. + const { mint: _b, ...bldSansMint } = bld; + const { mint: _i, ...istSansMint } = ist; + // XXX real bankManager does this. fake should too? + await makeWellKnownSpaces(agoricNamesAdmin, t.log, ['vbankAsset']); + await E(E(agoricNamesAdmin).lookupAdmin('vbankAsset')).update( + 'uist', + /** @type {AssetInfo} */ harden({ + brand: ist.brand, + issuer: ist.issuer, + issuerName: 'IST', + denom: 'uist', + proposedName: 'IST', + displayInfo: { IOU: true }, + }), + ); const transferBridge = makeFakeTransferBridge(rootZone); const { makeTransferMiddlewareKit, makeBridgeTargetKit } = @@ -65,7 +80,10 @@ export const commonSetup = async t => { ); finisher.useRegistry(bridgeTargetKit.targetRegistry); - const localchainBridge = makeFakeLocalchainBridge(rootZone); + const localBrigeMessages = [] as any[]; + const localchainBridge = makeFakeLocalchainBridge(rootZone, obj => + localBrigeMessages.push(obj), + ); const localchain = prepareLocalChainTools( rootZone.subZone('localchain'), ).makeLocalChain({ @@ -86,14 +104,12 @@ export const commonSetup = async t => { const { portAllocator } = fakeNetworkEchoStuff(rootZone.subZone('network')); const { public: orchestration } = makeOrchestrationKit({ portAllocator }); - const { nameHub: agoricNames, nameAdmin: agoricNamesAdmin } = - makeNameHubKit(); - await registerChainNamespace(agoricNamesAdmin, t.log); return { bootstrap: { agoricNames, + agoricNamesAdmin, bankManager, timer, localchain, @@ -103,15 +119,15 @@ export const commonSetup = async t => { storage, }, brands: { - // TODO consider omitting `issuer` to prevent minting, which the bank can't observe - bld, - ist, + bld: bldSansMint, + ist: istSansMint, }, commonPrivateArgs: { agoricNames, localchain, orchestrationService: orchestration, storageNode: storage.rootNode, + marshaller, timerService: timer, }, facadeServices: { @@ -122,6 +138,7 @@ export const commonSetup = async t => { }, utils: { pourPayment, + inspectLocalBridge: () => harden([...localBrigeMessages]), }, }; }; diff --git a/packages/orchestration/test/utils/time.test.ts b/packages/orchestration/test/utils/time.test.ts index b1da14af958..01f496814c3 100644 --- a/packages/orchestration/test/utils/time.test.ts +++ b/packages/orchestration/test/utils/time.test.ts @@ -13,7 +13,7 @@ test('makeTimestampHelper - getCurrentTimestamp', async t => { const timerBrand = timer.getTimerBrand(); t.is(timer.getCurrentTimestamp().absValue, 0n, 'current time is 0n'); - const { getTimeoutTimestampNS } = makeTimestampHelper(timer, timerBrand); + const { getTimeoutTimestampNS } = makeTimestampHelper(timer); await null; t.is( await getTimeoutTimestampNS(),