diff --git a/packages/fast-usdc/src/exos/operator-kit.js b/packages/fast-usdc/src/exos/operator-kit.js index 08b3ac9c368..7a98af3da5a 100644 --- a/packages/fast-usdc/src/exos/operator-kit.js +++ b/packages/fast-usdc/src/exos/operator-kit.js @@ -12,7 +12,7 @@ const trace = makeTracer('TxOperator'); /** * @typedef {object} OperatorPowers - * @property {(evidence: CctpTxEvidence, operatorKit: OperatorKit) => void} submitEvidence + * @property {(evidence: CctpTxEvidence, operatorId: string) => void} attest */ /** @@ -35,7 +35,7 @@ const OperatorKitI = { }), operator: M.interface('Operator', { - submitEvidence: M.call(CctpTxEvidenceShape).returns(M.promise()), + submitEvidence: M.call(CctpTxEvidenceShape).returns(), getStatus: M.call().returns(M.record()), }), }; @@ -87,7 +87,7 @@ export const prepareOperatorKit = (zone, staticPowers) => const { operator } = this.facets; // TODO(bootstrap integration): cause this call to throw and confirm that it // shows up in the the smart-wallet UpdateRecord `error` property - await operator.submitEvidence(evidence); + operator.submitEvidence(evidence); return staticPowers.makeInertInvitation( 'evidence was pushed in the invitation maker call', ); @@ -98,12 +98,12 @@ export const prepareOperatorKit = (zone, staticPowers) => * submit evidence from this operator * * @param {CctpTxEvidence} evidence + * @returns {void} */ - async submitEvidence(evidence) { + submitEvidence(evidence) { const { state } = this; !state.disabled || Fail`submitEvidence for disabled operator`; - const result = state.powers.submitEvidence(evidence, this.facets); - return result; + state.powers.attest(evidence, state.operatorId); }, /** @returns {OperatorStatus} */ getStatus() { diff --git a/packages/fast-usdc/src/exos/transaction-feed.js b/packages/fast-usdc/src/exos/transaction-feed.js index 782236d615f..231ef4f828d 100644 --- a/packages/fast-usdc/src/exos/transaction-feed.js +++ b/packages/fast-usdc/src/exos/transaction-feed.js @@ -1,6 +1,7 @@ import { makeTracer } from '@agoric/internal'; import { prepareDurablePublishKit } from '@agoric/notifier'; -import { M } from '@endo/patterns'; +import { keyEQ, M } from '@endo/patterns'; +import { Fail } from '@endo/errors'; import { CctpTxEvidenceShape } from '../type-guards.js'; import { defineInertInvitation } from '../utils/zoe.js'; import { prepareOperatorKit } from './operator-kit.js'; @@ -18,7 +19,7 @@ export const INVITATION_MAKERS_DESC = 'oracle operator invitation'; const TransactionFeedKitI = harden({ operatorPowers: M.interface('Transaction Feed Admin', { - submitEvidence: M.call(CctpTxEvidenceShape, M.any()).returns(), + attest: M.call(CctpTxEvidenceShape, M.string()).returns(), }), creator: M.interface('Transaction Feed Creator', { // TODO narrow the return shape to OperatorKit @@ -118,23 +119,16 @@ export const prepareTransactionFeedKit = (zone, zcf) => { /** * Add evidence from an operator. * + * NB: the operatorKit is responsible for + * * @param {CctpTxEvidence} evidence - * @param {OperatorKit} operatorKit + * @param {string} operatorId */ - submitEvidence(evidence, operatorKit) { - const { pending } = this.state; - trace( - 'submitEvidence', - operatorKit.operator.getStatus().operatorId, - evidence, - ); - const { operatorId } = operatorKit.operator.getStatus(); - - // TODO should this verify that the operator is one made by this exo? - // This doesn't work... - // operatorKit === operators.get(operatorId) || - // Fail`operatorKit mismatch`; + attest(evidence, operatorId) { + const { operators, pending } = this.state; + trace('submitEvidence', operatorId, evidence); + // TODO https://github.com/Agoric/agoric-sdk/pull/10720 // TODO validate that it's a valid for Fast USDC before accepting // E.g. that the `recipientAddress` is the FU settlement account and that // the EUD is a chain supported by FU. @@ -154,18 +148,46 @@ export const prepareTransactionFeedKit = (zone, zcf) => { const found = [...pending.values()].filter(store => store.has(txHash), ); - // TODO determine the real policy for checking agreement - if (found.length < pending.getSize()) { - // not all have seen it + const minAttestations = Math.ceil(operators.getSize() / 2); + trace( + 'transaction', + txHash, + 'has', + found.length, + 'of', + minAttestations, + 'necessary attestations', + ); + if (found.length < minAttestations) { return; } - // TODO verify that all found deep equal + let lastEvidence; + for (const store of found) { + const next = store.get(txHash); + if (lastEvidence) { + if (keyEQ(lastEvidence, next)) { + lastEvidence = next; + } else { + trace( + '🚨 conflicting evidence for', + txHash, + ':', + lastEvidence, + '!=', + next, + ); + Fail`conflicting evidence for ${txHash}`; + } + } + lastEvidence = next; + } - // all agree, so remove from pending and publish - for (const pendingStore of pending.values()) { - pendingStore.delete(txHash); + // sufficient agreement, so remove from pending and publish + for (const store of found) { + store.delete(txHash); } + trace('publishing evidence', evidence); publisher.publish(evidence); }, }, diff --git a/packages/fast-usdc/src/utils/deploy-config.js b/packages/fast-usdc/src/utils/deploy-config.js index 7b160a7996c..22b2cd5a593 100644 --- a/packages/fast-usdc/src/utils/deploy-config.js +++ b/packages/fast-usdc/src/utils/deploy-config.js @@ -156,3 +156,11 @@ export const configurations = { }, }; harden(configurations); + +// Constraints on the configurations +const MAINNET_EXPECTED_ORACLES = 3; +assert( + new Set(Object.values(configurations.MAINNET.oracles)).size === + MAINNET_EXPECTED_ORACLES, + `Mainnet must have exactly ${MAINNET_EXPECTED_ORACLES} oracles`, +); diff --git a/packages/fast-usdc/test/exos/transaction-feed.test.ts b/packages/fast-usdc/test/exos/transaction-feed.test.ts index 5f11c504dc2..8f5084af0ea 100644 --- a/packages/fast-usdc/test/exos/transaction-feed.test.ts +++ b/packages/fast-usdc/test/exos/transaction-feed.test.ts @@ -47,50 +47,87 @@ test('happy aggregation', async t => { const evidenceSubscriber = feedKit.public.getEvidenceSubscriber(); const { op1, op2, op3 } = await makeOperators(feedKit); - const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); - const results = await Promise.all([ - op1.operator.submitEvidence(evidence), - op2.operator.submitEvidence(evidence), - op3.operator.submitEvidence(evidence), - ]); - t.deepEqual(results, [undefined, undefined, undefined]); + const e1 = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + op1.operator.submitEvidence(e1); + op2.operator.submitEvidence(e1); + + // Publishes with 2 of 3 const accepted = await evidenceSubscriber.getUpdateSince(0); t.deepEqual(accepted, { - value: evidence, + value: e1, updateCount: 1n, }); - // verify that it doesn't publish until three match - await Promise.all([ - // once it publishes, it doesn't remember that it already saw these - op1.operator.submitEvidence(evidence), - op2.operator.submitEvidence(evidence), - // but this time the third is different - op3.operator.submitEvidence(MockCctpTxEvidences.AGORIC_PLUS_DYDX()), - ]); + // Now third operator catches up with same evidence already published + op3.operator.submitEvidence(e1); t.like(await evidenceSubscriber.getUpdateSince(0), { - // Update count is still 1 + // The confirming evidence doesn't change anything updateCount: 1n, }); - await op3.operator.submitEvidence(evidence); + + const e2 = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); + assert(e1.txHash !== e2.txHash); + op1.operator.submitEvidence(e2); + t.like(await evidenceSubscriber.getUpdateSince(0), { + // op1 attestation insufficient + updateCount: 1n, + }); +}); + +test('disagreement', async t => { + const feedKit = makeFeedKit(); + const { op1, op2 } = await makeOperators(feedKit); + const e1 = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + const e1bad = { ...e1, tx: { ...e1.tx, amount: 999_999_999n } }; + assert(e1.txHash === e1bad.txHash); + op1.operator.submitEvidence(e1); + + t.throws(() => op2.operator.submitEvidence(e1bad), { + message: + 'conflicting evidence for "0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702"', + }); +}); + +test('disagreement after publishing', async t => { + const feedKit = makeFeedKit(); + const evidenceSubscriber = feedKit.public.getEvidenceSubscriber(); + const { op1, op2, op3 } = await makeOperators(feedKit); + const e1 = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + const e1bad = { ...e1, tx: { ...e1.tx, amount: 999_999_999n } }; + assert(e1.txHash === e1bad.txHash); + op1.operator.submitEvidence(e1); + op2.operator.submitEvidence(e1); + + t.like(await evidenceSubscriber.getUpdateSince(0), { + updateCount: 1n, + }); + + // it's simply ignored + t.notThrows(() => op3.operator.submitEvidence(e1bad)); + t.like(await evidenceSubscriber.getUpdateSince(0), { + updateCount: 1n, + }); + + // now another op repeats the bad evidence, so it's published to the stream. + // It's the responsibility of the Advancer to fail because it has already processed that tx hash. + op1.operator.submitEvidence(e1bad); t.like(await evidenceSubscriber.getUpdateSince(0), { updateCount: 2n, }); }); -// TODO: find a way to get this working -test.skip('forged source', async t => { +test('disabled operator', async t => { const feedKit = makeFeedKit(); const { op1 } = await makeOperators(feedKit); const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); - // op1 is different than the facets object the evidence must come from - t.throws(() => - feedKit.operatorPowers.submitEvidence( - evidence, - // @ts-expect-error XXX Types of property '[GET_INTERFACE_GUARD]' are incompatible. - op1, - ), - ); + // works before disabling + op1.operator.submitEvidence(evidence); + + op1.admin.disable(); + + t.throws(() => op1.operator.submitEvidence(evidence), { + message: 'submitEvidence for disabled operator', + }); }); diff --git a/packages/fast-usdc/test/fast-usdc.contract.test.ts b/packages/fast-usdc/test/fast-usdc.contract.test.ts index f7869f3def1..bc73389a5b9 100644 --- a/packages/fast-usdc/test/fast-usdc.contract.test.ts +++ b/packages/fast-usdc/test/fast-usdc.contract.test.ts @@ -1,8 +1,11 @@ import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import type { ExecutionContext, TestFn } from 'ava'; +import { + decodeAddressHook, + encodeAddressHook, +} from '@agoric/cosmic-proto/address-hooks.js'; import { AmountMath } from '@agoric/ertp/src/amountMath.js'; -import { deeplyFulfilledObject } from '@agoric/internal'; import { eventLoopIteration, inspectMapStore, @@ -24,13 +27,9 @@ import { import type { Instance } from '@agoric/zoe/src/zoeService/utils.js'; import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; import { E } from '@endo/far'; -import { matches, objectMap } from '@endo/patterns'; +import { matches } from '@endo/patterns'; import { makePromiseKit } from '@endo/promise-kit'; import path from 'path'; -import { - decodeAddressHook, - encodeAddressHook, -} from '@agoric/cosmic-proto/address-hooks.js'; import type { OperatorKit } from '../src/exos/operator-kit.js'; import type { FastUsdcSF } from '../src/fast-usdc.contract.js'; import { PoolMetricsShape } from '../src/type-guards.js'; @@ -56,10 +55,12 @@ const getInvitationProperties = async ( return amount.value[0]; }; +// Spec for Mainnet. Other values are covered in unit tests of TransactionFeed. +const operatorQty = 3; + type CommonSetup = Awaited<ReturnType<typeof commonSetup>>; const startContract = async ( common: Pick<CommonSetup, 'brands' | 'commonPrivateArgs' | 'utils'>, - operatorQty = 1, ) => { const { brands: { usdc }, @@ -104,7 +105,7 @@ const makeTestContext = async (t: ExecutionContext) => { const common = await commonSetup(t); await E(common.mocks.ibcBridge).setAddressPrefix('noble'); - const startKit = await startContract(common, 2); + const startKit = await startContract(common); const { transferBridge } = common.mocks; const evm = makeEVM(); @@ -227,12 +228,17 @@ const makeOracleOperator = async ( ]); const { invitationMakers } = operatorKit; + let active = true; + return harden({ watch: () => { void observeIteration(subscribeEach(txSubscriber), { - updateState: tx => + updateState: tx => { + if (!active) { + return; + } // KLUDGE: tx wouldn't include aux. OCW looks it up - E.when( + return E.when( E(invitationMakers).SubmitEvidence(tx), inv => E.when(E(E(zoe).offer(inv)).getOfferResult(), res => { @@ -242,13 +248,17 @@ const makeOracleOperator = async ( reason => { failures.push(reason.message); }, - ), + ); + }, }); }, getDone: () => done, getFailures: () => harden([...failures]), // operator only gets .invitationMakers getKit: () => operatorKit, + setActive: flag => { + active = flag; + }, }); }; @@ -760,10 +770,12 @@ test.serial('Settlement for unknown transaction (operator down)', async t => { } = t.context; const operators = await sync.ocw.promise; + // Simulate 2 of 3 operators being unavailable + operators[0].setActive(false); + operators[1].setActive(false); + const opDown = makeCustomer('Otto', cctp, txPub.publisher, feeConfig); - // what removeOperator will do - await E(E.get(E(operators[1]).getKit()).admin).disable(); const bridgePos = snapshot(); const sent = await opDown.sendFast(t, 20_000_000n, 'osmo12345'); await mint(sent); @@ -788,9 +800,6 @@ test.serial('Settlement for unknown transaction (operator down)', async t => { t.deepEqual(bridgeTraffic.local, [], 'no IBC transfers'); await transmitTransferAck(); - t.deepEqual(await E(operators[1]).getFailures(), [ - 'submitEvidence for disabled operator', - ]); }); test.todo(