diff --git a/packages/orchestration/src/examples/sendAnywhere.contract.js b/packages/orchestration/src/examples/sendAnywhere.contract.js new file mode 100644 index 000000000000..ed49472cee21 --- /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/test/examples/sendAnywhere.test.ts b/packages/orchestration/test/examples/sendAnywhere.test.ts new file mode 100644 index 000000000000..234cef17f957 --- /dev/null +++ b/packages/orchestration/test/examples/sendAnywhere.test.ts @@ -0,0 +1,170 @@ +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'; + +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 { marshaller } = bootstrap; + const timerBrand = await E(bootstrap.timer).getTimerBrand(); + + const { zoe, bundleAndInstall } = await setUpZoeForTest(); + + t.log('contract coreEval', contractName); + + const installation: Installation = + await bundleAndInstall(contractFile); + + const { instance, creatorFacet } = await E(zoe).startInstance( + installation, + { Stable: ist.issuer }, + {}, + { + ...commonPrivateArgs, + storageNode: await E(bootstrap.storage.rootNode).makeChildNode( + contractName, + ), + }, + ); + + t.log('admin adds hot new chain to contract'); + const hotChainInfo = harden({ + chainId: 'hot-new-chain-0', + stakingTokens: [{ denom: 'uhot' }], + ...chainInfoDefaults, + }) as CosmosChainInfo; + 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; + t.log('add chain using creatorFacet', hotChainInfo.chainId); + const chainName = await E(creatorFacet).addChain( + hotChainInfo, + agoricToHotConnection, + ); + + t.log('client uses contract to send to hot new chain'); + const publicFacet = await E(zoe).getPublicFacet(instance); + const inv = E(publicFacet).makeSendInvitation(); + + const amt = await E(zoe).getInvitationDetails(inv); + t.is(amt.description, 'send'); + + const anAmt = ist.make(20n); + const Send = await pourPayment(anAmt); + const dest = { destAddr: 'hot1destAddr', chainName }; + const userSeat = await E(zoe).offer( + inv, + { give: { Send: anAmt } }, + { Send }, + dest, + ); + const result = await E(userSeat).getOfferResult(); + t.is(result, undefined); + + 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: '20', + denom: 'uist', + }, + }); +});