Skip to content

Commit

Permalink
transaction oracle invitations (#10478)
Browse files Browse the repository at this point in the history
refs: #10457

## Description

Adds access privileges for the transaction feed oracles. Accounts can be invited to be an oracle. The offer result is a kit that contains direct powers and an `invitationMakers` property for use through the smart-wallet.

Includes tests of executing invitations and disabling oracles. This PR doesn't close the issue because there is still the matter of combining oracle results. I plan to have more thorough tests in or after that PR.

### Security Considerations
provides an invitation to be an oracle to the transaction feed. These will eventually be tradable but aren't presently.

### Scaling Considerations
This hacks the semantics of continuing offers a bit by performing the request in the invitation making function, instead of in the course of the offer. It adds a `defineNoopInvitation` helper to explain this. It's not ideal but the pragmatic path for now ([decision record with rationale](https://alpha.decidedly.co/d/a8d1bfa1-f6d2-4b24-b636-d360b3227289)).

### Documentation Considerations
Adds a README with the basic sequence of how the oracles work with the contract

### Testing Considerations
More tests coming in other PRs. Some scheduled issues are specifically about testing.

### Upgrade Considerations
Not yet live but everything here is meant to be upgradable.
  • Loading branch information
mergify[bot] authored Nov 14, 2024
2 parents 493b453 + 8f4a66d commit ab3aa11
Show file tree
Hide file tree
Showing 9 changed files with 355 additions and 48 deletions.
39 changes: 39 additions & 0 deletions packages/fast-usdc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,42 @@ Over time we can update our tooling to decouple this more from the `packages` di
2. Export bootstrap testing utilities from `@agoric/boot`, allowing this to be above `@agoric/boot` in the package graph
3. Update CI to support packages that aren't under `packages/`, eg. a top-level `dapps` directory
4. Move this package out of agoric-sdk

# Transaction feed

## Oracles interface

Oracles run off-chain and interact with the contract via an Agoric smart wallet bridge.
```mermaid
sequenceDiagram
title Becoming an oracle operator
participant OW as Operator N<br/>Smart Wallet
participant FUC as Fast USDC<br/>Contract Exo
participant CE as Core Eval
CE->>FUC: makeOperatorInvitation()
FUC-->>CE: operatorInvitation
CE->>+OW: deposit(operatorInvitation)
Note left of FUC: Off-chain wallet accepts the operator invitation
OW->>+FUC: offer(operatorInvitation)
FUC-->>OW: operator invitationMakers: {SubmitEvidence}
Note left of FUC: Off-chain watcher detects evidence
OW->>+FUC: offer(SubmitEvidence, evidence)
```

```mermaid
sequenceDiagram
title Receiving evidence
participant W as Operator N<br/>Smart Wallet
participant A as Operator N<br/>Admin Oexo
participant TF as Transaction<br/>Feed
W->>A: offer(SubmitEvidence, evidence)
Note left of A: Once 3 operators push the same…
A->>TF: notify(evidence)
```
115 changes: 115 additions & 0 deletions packages/fast-usdc/src/exos/operator-kit.js
Original file line number Diff line number Diff line change
@@ -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<Invitation>}
*/
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<ReturnType<typeof prepareOperatorKit>>} OperatorKit */
103 changes: 88 additions & 15 deletions packages/fast-usdc/src/exos/transaction-feed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,20 +43,83 @@ export const prepareTransactionFeedKit = zone => {
/** @type {PublishKit<CctpTxEvidence>} */
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<string, OperatorKit>} */
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<Invitation<OperatorKit>>}
*/
makeOperatorInvitation(operatorId) {
const { admin } = this.facets;
trace('makeOperatorInvitation', operatorId);

return zcf.makeInvitation(
/** @type {OfferHandler<OperatorKit>} */
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);

Expand Down
21 changes: 14 additions & 7 deletions packages/fast-usdc/src/fast-usdc.contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import { prepareLiquidityPoolKit } from './exos/liquidity-pool.js';
import { prepareSettler } from './exos/settler.js';
import { prepareStatusManager } from './exos/status-manager.js';
import { prepareTransactionFeedKit } from './exos/transaction-feed.js';
import { defineInertInvitation } from './utils/zoe.js';

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';
*/

Expand Down Expand Up @@ -62,15 +64,15 @@ 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(
// @ts-expect-error FIXME
{},
);
// Connect evidence stream to advancer
void observeIteration(subscribeEach(feedKit.public.getEvidenceStream()), {
void observeIteration(subscribeEach(feedKit.public.getEvidenceSubscriber()), {
updateState(evidence) {
try {
advancer.handleTransactionEvent(evidence);
Expand All @@ -86,7 +88,16 @@ export const contract = async (zcf, privateArgs, zone, tools) => {
{ makeRecorderKit },
);

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

const creatorFacet = zone.exo('Fast USDC Creator', undefined, {
/** @type {(operatorId: string) => Promise<Invitation<OperatorKit>>} */
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
Expand All @@ -109,11 +120,7 @@ export const contract = async (zcf, privateArgs, zone, tools) => {
// 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 zcf.makeInvitation(async cSeat => {
trace('Offer made on noop invitation');
cSeat.exit();
return 'noop; evidence was pushed in the invitation maker call';
}, 'noop invitation');
return makeTestSubmissionInvitation();
},
makeDepositInvitation() {
// eslint-disable-next-line no-use-before-define
Expand Down
28 changes: 28 additions & 0 deletions packages/fast-usdc/src/utils/zoe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { makeTracer } from '@agoric/internal';

const trace = makeTracer('ZoeUtils');

/**
* Used for "continuing offer" invitations in which the caller does not need
* anything in return. In those cases there is no Zoe offer safety and the
* invitation making function can perform the request itself.
*
* But smart-wallet expects an invitation maker to make an invitation, so this
* function abstracts making such an inert invitation and logs consistently when
* it is used.
*
* When this is used by an invitation maker that performs the operation, receiving
* one of these invitations is evidence that the operation took place.
*
* @param {ZCF} zcf
* @param {string} description @see {@link ZCF.makeInvitation}
* @returns {() => Promise<Invitation>} an arg-less invitation maker
*/
export const defineInertInvitation = (zcf, description) => {
return () =>
zcf.makeInvitation(seat => {
trace(`ℹ️ An offer was made on an inert invitation for ${description}`);
seat.exit();
return 'inert; nothing should be expected from this offer';
}, description);
};
Loading

0 comments on commit ab3aa11

Please sign in to comment.