diff --git a/packages/fast-usdc/src/exos/operator-kit.js b/packages/fast-usdc/src/exos/operator-kit.js new file mode 100644 index 00000000000..28b1e07abe6 --- /dev/null +++ b/packages/fast-usdc/src/exos/operator-kit.js @@ -0,0 +1,115 @@ +import { makeTracer } from '@agoric/internal'; +import { Fail } from '@endo/errors'; +import { M } from '@endo/patterns'; +import { CctpTxEvidenceShape } from '../typeGuards.js'; + +const trace = makeTracer('TxOperator'); + +/** + * @import {Zone} from '@agoric/zone'; + * @import {CctpTxEvidence} from '../types.js'; + */ + +/** + * @typedef {object} OperatorStatus + * @property {boolean} [disabled] + * @property {string} operatorId + */ +/** + * @typedef {Readonly<{ operatorId: string }> & {disabled: boolean}} State + */ + +const OperatorKitI = { + admin: M.interface('Admin', { + disable: M.call().returns(), + }), + + invitationMakers: M.interface('InvitationMakers', { + SubmitEvidence: M.call(CctpTxEvidenceShape).returns(M.promise()), + }), + + operator: M.interface('Operator', { + submitEvidence: M.call(CctpTxEvidenceShape).returns(M.promise()), + getStatus: M.call().returns(M.record()), + }), +}; + +/** + * @param {Zone} zone + * @param {{handleEvidence: Function, makeInertInvitation: Function}} powers + */ +export const prepareOperatorKit = (zone, powers) => + zone.exoClassKit( + 'Operator Kit', + OperatorKitI, + /** + * @param {string} operatorId + * @returns {State} + */ + operatorId => { + return { + operatorId, + disabled: false, + }; + }, + { + admin: { + disable() { + trace(`operator ${this.state.operatorId} disabled`); + this.state.disabled = true; + }, + }, + /** + * NB: when this kit is an offer result, the smart-wallet will detect the `invitationMakers` + * key and save it for future offers. + */ + invitationMakers: { + /** + * Provide an API call in the form of an invitation maker, so that the + * capability is available in the smart-wallet bridge. + * + * NB: The `Invitation` object is evidence that the operation took + * place, rather than as a means of performing it as in the + * fluxAggregator contract used for price oracles. + * + * @param {CctpTxEvidence} evidence + * @returns {Promise} + */ + async SubmitEvidence(evidence) { + const { operator } = this.facets; + await operator.submitEvidence(evidence); + return powers.makeInertInvitation( + 'evidence was pushed in the invitation maker call', + ); + }, + }, + operator: { + /** + * submit evidence from this operator + * + * @param {CctpTxEvidence} evidence + */ + async submitEvidence(evidence) { + const { state } = this; + !state.disabled || Fail`submitEvidence for disabled operator`; + const result = await powers.handleEvidence( + { + operatorId: state.operatorId, + }, + evidence, + ); + return result; + }, + /** @returns {OperatorStatus} */ + getStatus() { + const { state } = this; + return { + operatorId: state.operatorId, + disabled: state.disabled, + }; + }, + }, + }, + ); + +/** @typedef {ReturnType>} OperatorKit */ diff --git a/packages/fast-usdc/src/exos/transaction-feed.js b/packages/fast-usdc/src/exos/transaction-feed.js index d39a3eebc00..37089593fc6 100644 --- a/packages/fast-usdc/src/exos/transaction-feed.js +++ b/packages/fast-usdc/src/exos/transaction-feed.js @@ -2,29 +2,39 @@ import { makeTracer } from '@agoric/internal'; import { prepareDurablePublishKit } from '@agoric/notifier'; import { M } from '@endo/patterns'; import { CctpTxEvidenceShape } from '../typeGuards.js'; +import { prepareOperatorKit } from './operator-kit.js'; +import { defineInertInvitation } from '../utils/zoe.js'; /** * @import {Zone} from '@agoric/zone'; + * @import {OperatorKit} from './operator-kit.js'; * @import {CctpTxEvidence} from '../types.js'; */ const trace = makeTracer('TxFeed', true); -export const INVITATION_MAKERS_DESC = 'transaction oracle invitation'; +/** Name in the invitation purse (keyed also by this contract instance) */ +export const INVITATION_MAKERS_DESC = 'oracle operator invitation'; const TransactionFeedKitI = harden({ admin: M.interface('Transaction Feed Admin', { submitEvidence: M.call(CctpTxEvidenceShape).returns(), + initOperator: M.call(M.string()).returns(M.promise()), + }), + creator: M.interface('Transaction Feed Creator', { + makeOperatorInvitation: M.call(M.string()).returns(M.promise()), + removeOperator: M.call(M.string()).returns(), }), public: M.interface('Transaction Feed Public', { - getEvidenceStream: M.call().returns(M.remotable()), + getEvidenceSubscriber: M.call().returns(M.remotable()), }), }); /** * @param {Zone} zone + * @param {ZCF} zcf */ -export const prepareTransactionFeedKit = zone => { +export const prepareTransactionFeedKit = (zone, zcf) => { const kinds = zone.mapStore('Kinds'); const makeDurablePublishKit = prepareDurablePublishKit( kinds, @@ -33,20 +43,83 @@ export const prepareTransactionFeedKit = zone => { /** @type {PublishKit} */ const { publisher, subscriber } = makeDurablePublishKit(); - return zone.exoClassKit('Fast USDC Feed', TransactionFeedKitI, () => ({}), { - admin: { - /** @param {CctpTxEvidence } evidence */ - submitEvidence: evidence => { - trace('TEMPORARY: Add evidence:', evidence); - // TODO decentralize - // TODO validate that it's valid to publish - publisher.publish(evidence); - }, - }, - public: { - getEvidenceStream: () => subscriber, + const makeInertInvitation = defineInertInvitation(zcf, 'submitting evidence'); + + const makeOperatorKit = prepareOperatorKit(zone, { + handleEvidence: (operatorId, evidence) => { + trace('handleEvidence', operatorId, evidence); }, + makeInertInvitation, }); + + return zone.exoClassKit( + 'Fast USDC Feed', + TransactionFeedKitI, + () => { + /** @type {MapStore} */ + const operators = zone.mapStore('operators', { + durable: true, + }); + return { operators }; + }, + { + creator: { + /** + * An "operator invitation" is an invitation to be an operator in the + * oracle netowrk, with the able to submit data to submit evidence of + * CCTP transactions. + * + * @param {string} operatorId unique per contract instance + * @returns {Promise>} + */ + makeOperatorInvitation(operatorId) { + const { admin } = this.facets; + trace('makeOperatorInvitation', operatorId); + + return zcf.makeInvitation( + /** @type {OfferHandler} */ + seat => { + seat.exit(); + return admin.initOperator(operatorId); + }, + INVITATION_MAKERS_DESC, + ); + }, + /** @param {string} operatorId */ + async removeOperator(operatorId) { + const { operators: oracles } = this.state; + trace('removeOperator', operatorId); + const kit = oracles.get(operatorId); + kit.admin.disable(); + oracles.delete(operatorId); + }, + }, + + admin: { + /** @param {string} operatorId */ + async initOperator(operatorId) { + const { operators: oracles } = this.state; + trace('initOperator', operatorId); + + const oracleKit = makeOperatorKit(operatorId); + oracles.init(operatorId, oracleKit); + + return oracleKit; + }, + + /** @param {CctpTxEvidence } evidence */ + submitEvidence: evidence => { + trace('TEMPORARY: Add evidence:', evidence); + // TODO decentralize + // TODO validate that it's valid to publish + publisher.publish(evidence); + }, + }, + public: { + getEvidenceSubscriber: () => subscriber, + }, + }, + ); }; harden(prepareTransactionFeedKit); diff --git a/packages/fast-usdc/src/fast-usdc.contract.js b/packages/fast-usdc/src/fast-usdc.contract.js index 51f46a6ab6d..82cd0e914bf 100644 --- a/packages/fast-usdc/src/fast-usdc.contract.js +++ b/packages/fast-usdc/src/fast-usdc.contract.js @@ -18,6 +18,7 @@ const trace = makeTracer('FastUsdc'); /** * @import {OrchestrationPowers, OrchestrationTools} from '@agoric/orchestration/src/utils/start-helper.js'; * @import {Zone} from '@agoric/zone'; + * @import {OperatorKit} from './exos/operator-kit.js'; * @import {CctpTxEvidence} from './types.js'; */ @@ -63,7 +64,7 @@ export const contract = async (zcf, privateArgs, zone, tools) => { statusManager, vowTools, }); - const makeFeedKit = prepareTransactionFeedKit(zone); + const makeFeedKit = prepareTransactionFeedKit(zone, zcf); assertAllDefined({ makeFeedKit, makeAdvancer, makeSettler, statusManager }); const feedKit = makeFeedKit(); const advancer = makeAdvancer( @@ -71,7 +72,7 @@ export const contract = async (zcf, privateArgs, zone, tools) => { {}, ); // Connect evidence stream to advancer - void observeIteration(subscribeEach(feedKit.public.getEvidenceStream()), { + void observeIteration(subscribeEach(feedKit.public.getEvidenceSubscriber()), { updateState(evidence) { try { advancer.handleTransactionEvent(evidence); @@ -93,6 +94,10 @@ export const contract = async (zcf, privateArgs, zone, tools) => { ); const creatorFacet = zone.exo('Fast USDC Creator', undefined, { + /** @type {(operatorId: string) => Promise>} */ + async makeOperatorInvitation(operatorId) { + return feedKit.creator.makeOperatorInvitation(operatorId); + }, simulateFeesFromAdvance(amount, payment) { console.log('🚧🚧 UNTIL: advance fees are implemented 🚧🚧'); // eslint-disable-next-line no-use-before-define diff --git a/packages/fast-usdc/test/exos/transaction-feed.test.ts b/packages/fast-usdc/test/exos/transaction-feed.test.ts index 5688b9db73c..bd75a41668f 100644 --- a/packages/fast-usdc/test/exos/transaction-feed.test.ts +++ b/packages/fast-usdc/test/exos/transaction-feed.test.ts @@ -4,8 +4,10 @@ import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import { makeHeapZone } from '@agoric/zone'; import { prepareTransactionFeedKit } from '../../src/exos/transaction-feed.js'; +const nullZcf = null as any; + test('basics', t => { const zone = makeHeapZone(); - const kit = prepareTransactionFeedKit(zone); + const kit = prepareTransactionFeedKit(zone, nullZcf); t.deepEqual(Object.values(kit), []); }); diff --git a/packages/fast-usdc/test/fast-usdc.contract.test.ts b/packages/fast-usdc/test/fast-usdc.contract.test.ts index 63a16306c16..78385061afd 100644 --- a/packages/fast-usdc/test/fast-usdc.contract.test.ts +++ b/packages/fast-usdc/test/fast-usdc.contract.test.ts @@ -16,10 +16,18 @@ import { commonSetup } from './supports.js'; const dirname = path.dirname(new URL(import.meta.url).pathname); -const contractName = 'fast-usdc'; const contractFile = `${dirname}/../src/fast-usdc.contract.js`; type StartFn = typeof import('../src/fast-usdc.contract.js').start; +const getInvitationProperties = async ( + zoe: ZoeService, + invitation: Invitation, +) => { + const invitationIssuer = E(zoe).getInvitationIssuer(); + const amount = await E(invitationIssuer).getAmountOf(invitation); + return amount.value[0]; +}; + const startContract = async ( common: Pick< Awaited>, @@ -70,6 +78,42 @@ test('start', async t => { ); }); +test('oracle operators have closely-held rights to submit evidence of CCTP transactions', async t => { + const common = await commonSetup(t); + const { creatorFacet, zoe } = await startContract(common); + + const operatorId = 'operator-1'; + + const opInv = await E(creatorFacet).makeOperatorInvitation(operatorId); + + t.like(await getInvitationProperties(zoe, opInv), { + description: 'oracle operator invitation', + }); + + const operatorKit = await E(E(zoe).offer(opInv)).getOfferResult(); + + t.deepEqual(Object.keys(operatorKit), [ + 'admin', + 'invitationMakers', + 'operator', + ]); + + const e1 = MockCctpTxEvidences.AGORIC_NO_PARAMS(); + + { + const inv = await E(operatorKit.invitationMakers).SubmitEvidence(e1); + const res = await E(E(zoe).offer(inv)).getOfferResult(); + t.is(res, 'inert; nothing should be expected from this offer'); + } + + // what removeOperator will do + await E(operatorKit.admin).disable(); + + await t.throwsAsync(E(operatorKit.invitationMakers).SubmitEvidence(e1), { + message: 'submitEvidence for disabled operator', + }); +}); + const logAmt = amt => [ Number(amt.value), // numberWithCommas(Number(amt.value)), diff --git a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md index 8a13071ad97..3b8e2137c8a 100644 --- a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md +++ b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md @@ -62,11 +62,13 @@ Generated by [AVA](https://avajs.dev). withdrawHandler: Object @Alleged: Liquidity Pool withdrawHandler {}, }, 'Liquidity Pool_kindHandle': 'Alleged: kind', + 'Operator Kit_kindHandle': 'Alleged: kind', PendingTxs: {}, SeenTxs: [], mint: { PoolShare: 'Alleged: zcfMint', }, + operators: {}, orchestration: {}, vstorage: { 'Durable Publish Kit_kindHandle': 'Alleged: kind', diff --git a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap index 996a38d4eb9..760d5257ffc 100644 Binary files a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap and b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap differ