Skip to content

Commit

Permalink
FU transaction feed aggregation (#10489)
Browse files Browse the repository at this point in the history
closes: #10457

## Description

This implements aggregation of the evidence from oracle operators. Once they all agree it forwards.

Some corner cases are deferred for our scheduled error handling work.

A testing change was to make the contract's public `makeTestPushInvitation` handle evidence directly instead of putting it through the feed. The feed has its own tests and the purpose of the test invitation is to let a client trigger an advance, not test the feed. A consequence of this is that the existing contract test started advancing, which doesn't work. So I marked the test skip until it does.

### Security Considerations
Decentralizes truth of CCTP transaction evidence, split among oracle operators. It waits until all operators have sent evidence of the same transaction. There is more verification to do. (See TODO items)

### Scaling Considerations
Small number of new Ecos (one per oracle operator, probably three). The pending transactions are stored in a mapstore until they're resolved. Transactions that are never matched by all operators don't ever get cleaned up. It doesn't keep track of transactions it has published, so this could easily happen if one operator double-reports and the others don't. (If they all double-report, the transaction will repeat… hmmm this needs more work)

### Documentation Considerations

To follow up with oracle operators

### Testing Considerations
CI

### Upgrade Considerations
Not yet deployed but all the exos are meant be upgraded
  • Loading branch information
mergify[bot] authored Nov 15, 2024
2 parents 0fa2d27 + d06ae2b commit 1682675
Show file tree
Hide file tree
Showing 7 changed files with 197 additions and 54 deletions.
27 changes: 16 additions & 11 deletions packages/fast-usdc/src/exos/operator-kit.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ const trace = makeTracer('TxOperator');
* @import {CctpTxEvidence} from '../types.js';
*/

/**
* @typedef {object} OperatorPowers
* @property {(evidence: CctpTxEvidence, operatorKit: OperatorKit) => void} submitEvidence
*/

/**
* @typedef {object} OperatorStatus
* @property {boolean} [disabled]
* @property {string} operatorId
*/

/**
* @typedef {Readonly<{ operatorId: string }> & {disabled: boolean}} State
* @typedef {Readonly<{ operatorId: string, powers: OperatorPowers }> & {disabled: boolean}} State
*/

const OperatorKitI = {
Expand All @@ -36,19 +42,21 @@ const OperatorKitI = {

/**
* @param {Zone} zone
* @param {{handleEvidence: Function, makeInertInvitation: Function}} powers
* @param {{ makeInertInvitation: Function }} staticPowers
*/
export const prepareOperatorKit = (zone, powers) =>
export const prepareOperatorKit = (zone, staticPowers) =>
zone.exoClassKit(
'Operator Kit',
OperatorKitI,
/**
* @param {string} operatorId
* @param {OperatorPowers} powers facet of the durable transaction feed
* @returns {State}
*/
operatorId => {
(operatorId, powers) => {
return {
operatorId,
powers,
disabled: false,
};
},
Expand Down Expand Up @@ -77,8 +85,10 @@ export const prepareOperatorKit = (zone, powers) =>
*/
async SubmitEvidence(evidence) {
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);
return powers.makeInertInvitation(
return staticPowers.makeInertInvitation(
'evidence was pushed in the invitation maker call',
);
},
Expand All @@ -92,12 +102,7 @@ export const prepareOperatorKit = (zone, powers) =>
async submitEvidence(evidence) {
const { state } = this;
!state.disabled || Fail`submitEvidence for disabled operator`;
const result = await powers.handleEvidence(
{
operatorId: state.operatorId,
},
evidence,
);
const result = state.powers.submitEvidence(evidence, this.facets);
return result;
},
/** @returns {OperatorStatus} */
Expand Down
112 changes: 83 additions & 29 deletions packages/fast-usdc/src/exos/transaction-feed.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ 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 { prepareOperatorKit } from './operator-kit.js';

/**
* @import {Zone} from '@agoric/zone';
Expand All @@ -17,11 +17,12 @@ const trace = makeTracer('TxFeed', true);
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()),
operatorPowers: M.interface('Transaction Feed Admin', {
submitEvidence: M.call(CctpTxEvidenceShape, M.any()).returns(),
}),
creator: M.interface('Transaction Feed Creator', {
// TODO narrow the return shape to OperatorKit
initOperator: M.call(M.string()).returns(M.record()),
makeOperatorInvitation: M.call(M.string()).returns(M.promise()),
removeOperator: M.call(M.string()).returns(),
}),
Expand All @@ -46,9 +47,6 @@ export const prepareTransactionFeedKit = (zone, zcf) => {
const makeInertInvitation = defineInertInvitation(zcf, 'submitting evidence');

const makeOperatorKit = prepareOperatorKit(zone, {
handleEvidence: (operatorId, evidence) => {
trace('handleEvidence', operatorId, evidence);
},
makeInertInvitation,
});

Expand All @@ -60,7 +58,11 @@ export const prepareTransactionFeedKit = (zone, zcf) => {
const operators = zone.mapStore('operators', {
durable: true,
});
return { operators };
/** @type {MapStore<string, MapStore<string, CctpTxEvidence>>} */
const pending = zone.mapStore('pending', {
durable: true,
});
return { operators, pending };
},
{
creator: {
Expand All @@ -73,45 +75,97 @@ export const prepareTransactionFeedKit = (zone, zcf) => {
* @returns {Promise<Invitation<OperatorKit>>}
*/
makeOperatorInvitation(operatorId) {
const { admin } = this.facets;
const { creator } = this.facets;
trace('makeOperatorInvitation', operatorId);

return zcf.makeInvitation(
/** @type {OfferHandler<OperatorKit>} */
seat => {
seat.exit();
return admin.initOperator(operatorId);
return creator.initOperator(operatorId);
},
INVITATION_MAKERS_DESC,
);
},
/** @param {string} operatorId */
initOperator(operatorId) {
const { operators, pending } = this.state;
trace('initOperator', operatorId);

const operatorKit = makeOperatorKit(
operatorId,
this.facets.operatorPowers,
);
operators.init(operatorId, operatorKit);
pending.init(
operatorId,
zone.detached().mapStore('pending evidence'),
);

return operatorKit;
},

/** @param {string} operatorId */
async removeOperator(operatorId) {
const { operators: oracles } = this.state;
const { operators } = this.state;
trace('removeOperator', operatorId);
const kit = oracles.get(operatorId);
kit.admin.disable();
oracles.delete(operatorId);
const operatorKit = operators.get(operatorId);
operatorKit.admin.disable();
operators.delete(operatorId);
},
},
operatorPowers: {
/**
* Add evidence from an operator.
*
* @param {CctpTxEvidence} evidence
* @param {OperatorKit} operatorKit
*/
submitEvidence(evidence, operatorKit) {
const { pending } = this.state;
trace(
'submitEvidence',
operatorKit.operator.getStatus().operatorId,
evidence,
);
const { operatorId } = operatorKit.operator.getStatus();

admin: {
/** @param {string} operatorId */
async initOperator(operatorId) {
const { operators: oracles } = this.state;
trace('initOperator', operatorId);
// TODO should this verify that the operator is one made by this exo?
// This doesn't work...
// operatorKit === operators.get(operatorId) ||
// Fail`operatorKit mismatch`;

const oracleKit = makeOperatorKit(operatorId);
oracles.init(operatorId, oracleKit);
// 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;

return oracleKit;
},
// accept the evidence
{
const pendingStore = pending.get(operatorId);
if (pendingStore.has(txHash)) {
trace(`operator ${operatorId} already reported ${txHash}`);
} else {
pendingStore.init(txHash, evidence);
}
}

// check agreement
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
return;
}

// TODO verify that all found deep equal

/** @param {CctpTxEvidence } evidence */
submitEvidence: evidence => {
trace('TEMPORARY: Add evidence:', evidence);
// TODO decentralize
// TODO validate that it's valid to publish
// all agree, so remove from pending and publish
for (const pendingStore of pending.values()) {
pendingStore.delete(txHash);
}
publisher.publish(evidence);
},
},
Expand All @@ -123,4 +177,4 @@ export const prepareTransactionFeedKit = (zone, zcf) => {
};
harden(prepareTransactionFeedKit);

/** @typedef {ReturnType<typeof prepareTransactionFeedKit>} TransactionFeedKit */
/** @typedef {ReturnType<ReturnType<typeof prepareTransactionFeedKit>>} TransactionFeedKit */
14 changes: 6 additions & 8 deletions packages/fast-usdc/src/fast-usdc.contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ export const contract = async (zcf, privateArgs, zone, tools) => {
{ makeRecorderKit },
);

const makeTestSubmissionInvitation = defineInertInvitation(
const makeTestInvitation = defineInertInvitation(
zcf,
'test of submitting evidence',
'test of forcing evidence',
);

const creatorFacet = zone.exo('Fast USDC Creator', undefined, {
Expand All @@ -109,18 +109,16 @@ export const contract = async (zcf, privateArgs, zone, tools) => {
// XXX to be removed before production
/**
* NB: Any caller with access to this invitation maker has the ability to
* add evidence.
* force handling of evidence.
*
* Provide an API call in the form of an invitation maker, so that the
* capability is available in the smart-wallet bridge.
* capability is available in the smart-wallet bridge during UI testing.
*
* @param {CctpTxEvidence} evidence
*/
makeTestPushInvitation(evidence) {
// TODO(bootstrap integration): force this to throw and confirm that it
// shows up in the the smart-wallet UpdateRecord `error` property
feedKit.admin.submitEvidence(evidence);
return makeTestSubmissionInvitation();
advancer.handleTransactionEvent(evidence);
return makeTestInvitation();
},
makeDepositInvitation() {
// eslint-disable-next-line no-use-before-define
Expand Down
91 changes: 87 additions & 4 deletions packages/fast-usdc/test/exos/transaction-feed.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,96 @@
// Must be first to set up globals
import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js';

import { deeplyFulfilledObject } from '@agoric/internal';
import { makeHeapZone } from '@agoric/zone';
import { prepareTransactionFeedKit } from '../../src/exos/transaction-feed.js';
import {
prepareTransactionFeedKit,
type TransactionFeedKit,
} from '../../src/exos/transaction-feed.js';
import { MockCctpTxEvidences } from '../fixtures.js';

const nullZcf = null as any;

test('basics', t => {
const makeFeedKit = () => {
const zone = makeHeapZone();
const kit = prepareTransactionFeedKit(zone, nullZcf);
t.deepEqual(Object.values(kit), []);
const makeKit = prepareTransactionFeedKit(zone, nullZcf);
return makeKit();
};

const makeOperators = (feedKit: TransactionFeedKit) => {
const operators = Object.fromEntries(
['op1', 'op2', 'op3'].map(name => [
name,
feedKit.creator.initOperator(name),
]),
);
return deeplyFulfilledObject(harden(operators));
};

test('facets', t => {
const kit = makeFeedKit();
t.deepEqual(Object.keys(kit).sort(), ['creator', 'operatorPowers', 'public']);
});

test('status shape', async t => {
const { op1 } = await makeOperators(makeFeedKit());

// status shape
t.deepEqual(op1.operator.getStatus(), {
disabled: false,
operatorId: 'op1',
});
});

test('happy aggregation', async t => {
const feedKit = makeFeedKit();
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 accepted = await evidenceSubscriber.getUpdateSince(0);
t.deepEqual(accepted, {
value: evidence,
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()),
]);
t.like(await evidenceSubscriber.getUpdateSince(0), {
// Update count is still 1
updateCount: 1n,
});
await op3.operator.submitEvidence(evidence);
t.like(await evidenceSubscriber.getUpdateSince(0), {
updateCount: 2n,
});
});

// TODO: find a way to get this working
test.skip('forged source', 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,
),
);
});
6 changes: 4 additions & 2 deletions packages/fast-usdc/test/fast-usdc.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,14 @@ const startContract = async (
return { ...startKit, zoe };
};

test('start', async t => {
// FIXME this makeTestPushInvitation forces evidence, which triggers advancing,
// which doesn't yet work
test.skip('advancing', async t => {
const common = await commonSetup(t);

const { publicFacet, zoe } = await startContract(common);

const e1 = await E(MockCctpTxEvidences.AGORIC_NO_PARAMS)();
const e1 = await E(MockCctpTxEvidences.AGORIC_PLUS_DYDX)();

const inv = await E(publicFacet).makeTestPushInvitation(e1);
// the invitation maker itself pushes the evidence
Expand Down
Loading

0 comments on commit 1682675

Please sign in to comment.