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..21e8bd13a0c 100644 --- a/packages/fast-usdc/src/exos/transaction-feed.js +++ b/packages/fast-usdc/src/exos/transaction-feed.js @@ -1,6 +1,8 @@ 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 { decodeAddressHook } from '@agoric/cosmic-proto/address-hooks.js'; import { CctpTxEvidenceShape } from '../type-guards.js'; import { defineInertInvitation } from '../utils/zoe.js'; import { prepareOperatorKit } from './operator-kit.js'; @@ -18,7 +20,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 @@ -53,7 +55,11 @@ export const prepareTransactionFeedKit = (zone, zcf) => { return zone.exoClassKit( 'Fast USDC Feed', TransactionFeedKitI, - () => { + /** + * @param {import('@agoric/orchestration').ChainAddress} settlementAccountAddress + */ + settlementAccountAddress => { + assert(settlementAccountAddress, 'missing settlementAccountAddress'); /** @type {MapStore} */ const operators = zone.mapStore('operators', { durable: true, @@ -62,7 +68,7 @@ export const prepareTransactionFeedKit = (zone, zcf) => { const pending = zone.mapStore('pending', { durable: true, }); - return { operators, pending }; + return { operators, pending, settlementAccountAddress }; }, { creator: { @@ -118,27 +124,33 @@ 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, + attest(evidence, operatorId) { + const { operators, pending, settlementAccountAddress } = this.state; + trace('submitEvidence', operatorId, evidence); + + const { + aux: { recipientAddress }, + tx: { forwardingAddress }, + txHash, + } = evidence; + assert( + forwardingAddress.startsWith('noble'), + 'only Noble forwarding supported', ); - 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`; - - // 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. - const { txHash } = evidence; + const hook = decodeAddressHook(recipientAddress); + assert.equal( + hook.baseAddress, + settlementAccountAddress.value, + 'only Fast USDC settlementAccount supported as recipient', + ); + // We could also verify that hook.query.EUD is chain officially + // supported by Fast USDC. We filter upstream to give the on-chain + // contract more flexibility. // accept the evidence { @@ -154,18 +166,51 @@ 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 + trace('found these stores with the txHash', found.length); + 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( + 'stores with the txHash after delete()', + [...pending.values()].filter(store => store.has(txHash)).length, + ); + trace('publishing evidence', evidence); publisher.publish(evidence); }, }, diff --git a/packages/fast-usdc/src/fast-usdc.contract.js b/packages/fast-usdc/src/fast-usdc.contract.js index 80f8c4a6df1..73cfe27c70b 100644 --- a/packages/fast-usdc/src/fast-usdc.contract.js +++ b/packages/fast-usdc/src/fast-usdc.contract.js @@ -270,8 +270,6 @@ export const contract = async (zcf, privateArgs, zone, tools) => { const nobleAccountV = zone.makeOnce('NobleAccount', () => makeNobleAccount()); - const feedKit = zone.makeOnce('Feed Kit', () => makeFeedKit()); - const poolAccountV = zone.makeOnce('PoolAccount', () => makeLocalAccount()); const settleAccountV = zone.makeOnce('SettleAccount', () => makeLocalAccount(), @@ -284,6 +282,10 @@ export const contract = async (zcf, privateArgs, zone, tools) => { trace('settlementAccount', settlementAccount); trace('poolAccount', poolAccount); + const feedKit = zone.makeOnce('Feed Kit', () => + makeFeedKit(settlementAccount.getAddress()), + ); + const [_agoric, _noble, agToNoble] = await vowTools.when( chainHub.getChainsAndConnection('agoric', 'noble'), ); 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..6f2937e6dc7 100644 --- a/packages/fast-usdc/test/exos/transaction-feed.test.ts +++ b/packages/fast-usdc/test/exos/transaction-feed.test.ts @@ -3,18 +3,27 @@ import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import { deeplyFulfilledObject } from '@agoric/internal'; import { makeHeapZone } from '@agoric/zone'; +import type { ChainAddress } from '@agoric/orchestration'; import { prepareTransactionFeedKit, type TransactionFeedKit, } from '../../src/exos/transaction-feed.js'; -import { MockCctpTxEvidences } from '../fixtures.js'; +import { + MockCctpTxEvidences, + mockSettlementAccountAddress, +} from '../fixtures.js'; const nullZcf = null as any; +const settlementAccountAddress: ChainAddress = { + chainId: 'agoric', + value: mockSettlementAccountAddress, + encoding: 'bech32', +}; const makeFeedKit = () => { const zone = makeHeapZone(); const makeKit = prepareTransactionFeedKit(zone, nullZcf); - return makeKit(); + return makeKit(settlementAccountAddress); }; const makeOperators = (feedKit: TransactionFeedKit) => { @@ -47,50 +56,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..a4b04044454 100644 --- a/packages/fast-usdc/test/fast-usdc.contract.test.ts +++ b/packages/fast-usdc/test/fast-usdc.contract.test.ts @@ -36,7 +36,10 @@ import type { FastUsdcSF } from '../src/fast-usdc.contract.js'; import { PoolMetricsShape } from '../src/type-guards.js'; import type { CctpTxEvidence, FeeConfig, PoolMetrics } from '../src/types.js'; import { makeFeeTools } from '../src/utils/fees.js'; -import { MockCctpTxEvidences } from './fixtures.js'; +import { + MockCctpTxEvidences, + mockSettlementAccountAddress, +} from './fixtures.js'; import { commonSetup, uusdcOnAgoric } from './supports.js'; const dirname = path.dirname(new URL(import.meta.url).pathname); @@ -135,7 +138,7 @@ const makeTestContext = async (t: ExecutionContext) => { }; const mint = async (e: CctpTxEvidence) => { - const settlerAddr = 'agoric1fakeLCAAddress1'; // TODO: get from contract + const settlerAddr = mockSettlementAccountAddress; // TODO: get from contract const rxd = await receiveUSDCAt(settlerAddr, e.tx.amount); await VE(transferBridge).fromBridge( buildVTransferEvent({ @@ -190,7 +193,7 @@ test('getStaticInfo', async t => { t.deepEqual(await E(publicFacet).getStaticInfo(), { addresses: { poolAccount: 'agoric1fakeLCAAddress', - settlementAccount: 'agoric1fakeLCAAddress1', + settlementAccount: mockSettlementAccountAddress, }, }); }); @@ -549,7 +552,7 @@ test.serial('STORY01: advancing happy path for 100 USDC', async t => { t.deepEqual(inspectBankBridge().at(-1), { amount: String(expectedAdvance.value), denom: uusdcOnAgoric, - recipient: 'agoric1fakeLCAAddress', + recipient: mockSettlementAccountAddress, type: 'VBANK_GIVE', }); @@ -779,7 +782,7 @@ test.serial('Settlement for unknown transaction (operator down)', async t => { }, { amount: '20000000', - recipient: 'agoric1fakeLCAAddress1', + recipient: mockSettlementAccountAddress, type: 'VBANK_GIVE', }, ], diff --git a/packages/fast-usdc/test/fixtures.ts b/packages/fast-usdc/test/fixtures.ts index 35ec0c231e2..d31cf312f37 100644 --- a/packages/fast-usdc/test/fixtures.ts +++ b/packages/fast-usdc/test/fixtures.ts @@ -14,6 +14,9 @@ const mockScenarios = [ type MockScenario = (typeof mockScenarios)[number]; +export const mockSettlementAccountAddress = + 'agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek'; + export const Senders = { default: '0xDefaultFakeEthereumAddress', } as unknown as Record; @@ -37,10 +40,9 @@ export const MockCctpTxEvidences: Record< forwardingChannel: 'channel-21', recipientAddress: receiverAddress || - encodeAddressHook( - 'agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek', - { EUD: 'osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men' }, - ), + encodeAddressHook(mockSettlementAccountAddress, { + EUD: 'osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + }), }, chainId: 1, }), @@ -59,10 +61,9 @@ export const MockCctpTxEvidences: Record< forwardingChannel: 'channel-21', recipientAddress: receiverAddress || - encodeAddressHook( - 'agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek', - { EUD: 'dydx183dejcnmkka5dzcu9xw6mywq0p2m5peks28men' }, - ), + encodeAddressHook(mockSettlementAccountAddress, { + EUD: 'dydx183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + }), }, chainId: 1, }), @@ -79,9 +80,7 @@ export const MockCctpTxEvidences: Record< }, aux: { forwardingChannel: 'channel-21', - recipientAddress: - receiverAddress || - 'agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek', + recipientAddress: receiverAddress || mockSettlementAccountAddress, }, chainId: 1, }), @@ -100,10 +99,7 @@ export const MockCctpTxEvidences: Record< forwardingChannel: 'channel-21', recipientAddress: receiverAddress || - encodeAddressHook( - 'agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek', - { EUD: 'random1addr' }, - ), + encodeAddressHook(mockSettlementAccountAddress, { EUD: 'random1addr' }), }, chainId: 1, }), diff --git a/packages/fast-usdc/test/supports.ts b/packages/fast-usdc/test/supports.ts index 8ecdc66bdb6..e06b26275e8 100644 --- a/packages/fast-usdc/test/supports.ts +++ b/packages/fast-usdc/test/supports.ts @@ -190,7 +190,9 @@ export const commonSetup = async (t: ExecutionContext) => { ibcSequenceNonce += 1n; // let the promise for the transfer start await eventLoopIteration(); - const lastMsgTransfer = localBridgeMessages.at(-1).messages[0]; + const lastLBM = localBridgeMessages.at(-1); + assert('messages' in lastLBM, 'expected a different localBridgeMessage'); + const lastMsgTransfer = lastLBM.messages[0]; await E(transferBridge).fromBridge( buildVTransferEvent({ receiver: lastMsgTransfer.receiver,