Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

transaction feed validates settlement account #10720

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions packages/fast-usdc/src/exos/operator-kit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/

/**
Expand All @@ -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()),
}),
};
Expand Down Expand Up @@ -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',
);
Expand All @@ -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() {
Expand Down
103 changes: 74 additions & 29 deletions packages/fast-usdc/src/exos/transaction-feed.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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<string, OperatorKit>} */
const operators = zone.mapStore('operators', {
durable: true,
Expand All @@ -62,7 +68,7 @@ export const prepareTransactionFeedKit = (zone, zcf) => {
const pending = zone.mapStore('pending', {
durable: true,
});
return { operators, pending };
return { operators, pending, settlementAccountAddress };
},
{
creator: {
Expand Down Expand Up @@ -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
{
Expand All @@ -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);
},
},
Expand Down
6 changes: 4 additions & 2 deletions packages/fast-usdc/src/fast-usdc.contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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'),
);
Expand Down
8 changes: 8 additions & 0 deletions packages/fast-usdc/src/utils/deploy-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
);
106 changes: 76 additions & 30 deletions packages/fast-usdc/test/exos/transaction-feed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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',
});
});
Loading
Loading