diff --git a/packages/boot/test/fast-usdc/fast-usdc.test.ts b/packages/boot/test/fast-usdc/fast-usdc.test.ts index 38b2268e2c4..60850171ada 100644 --- a/packages/boot/test/fast-usdc/fast-usdc.test.ts +++ b/packages/boot/test/fast-usdc/fast-usdc.test.ts @@ -311,6 +311,56 @@ test.serial('makes usdc advance', async t => { await documentStorageSchema(t, storage, doc); }); +test.serial('skips usdc advance when risks identified', async t => { + const { walletFactoryDriver: wd, storage } = t.context; + const oracles = await Promise.all([ + wd.provideSmartWallet('agoric19uscwxdac6cf6z7d5e26e0jm0lgwstc47cpll8'), + wd.provideSmartWallet('agoric1krunjcqfrf7la48zrvdfeeqtls5r00ep68mzkr'), + wd.provideSmartWallet('agoric1n4fcxsnkxe4gj6e24naec99hzmc4pjfdccy5nj'), + ]); + + const EUD = 'dydx1riskyeud'; + const lastNodeValue = storage.getValues('published.fastUsdc').at(-1); + const { settlementAccount } = JSON.parse(NonNullish(lastNodeValue)); + const evidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX( + // mock with the read settlementAccount address + encodeAddressHook(settlementAccount, { EUD }), + ); + + await Promise.all( + oracles.map(wallet => + wallet.sendOffer({ + id: 'submit-mock-evidence-dydx-risky', + invitationSpec: { + source: 'continuing', + previousOffer: 'claim-oracle-invitation', + invitationMakerName: 'SubmitEvidence', + invitationArgs: [evidence, { risksIdentified: ['TOO_LARGE_AMOUNT'] }], + }, + proposal: {}, + }), + ), + ); + await eventLoopIteration(); + + t.deepEqual( + storage + .getValues(`published.fastUsdc.txns.${evidence.txHash}`) + .map(defaultSerializer.parse), + [ + { evidence, status: 'OBSERVED' }, // observation includes evidence observed + { status: 'ADVANCE_SKIPPED', risksIdentified: ['TOO_LARGE_AMOUNT'] }, + ], + ); + + const doc = { + node: `fastUsdc.txns`, + owner: `the Ethereum transactions upon which Fast USDC is acting`, + showValue: defaultSerializer.parse, + }; + await documentStorageSchema(t, storage, doc); +}); + test.serial('restart contract', async t => { const { EV } = t.context.runUtils; await null; diff --git a/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.md b/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.md index ace588bbdf2..726ac84a4ac 100644 --- a/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.md +++ b/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.md @@ -174,3 +174,28 @@ Generated by [AVA](https://avajs.dev). }, ], ] + +## skips usdc advance when risks identified + +> Under "published", the "fastUsdc.txns" node is delegated to the Ethereum transactions upon which Fast USDC is acting. +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.fastUsdc.txns.0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702', + { + status: 'ADVANCING', + }, + ], + [ + 'published.fastUsdc.txns.0xd81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761799', + { + risksIdentified: [ + 'TOO_LARGE_AMOUNT', + ], + status: 'ADVANCE_SKIPPED', + }, + ], + ] diff --git a/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.snap b/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.snap index 0098b4788bf..8528f3eda7c 100644 Binary files a/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.snap and b/packages/boot/test/fast-usdc/snapshots/fast-usdc.test.ts.snap differ diff --git a/packages/fast-usdc/README.md b/packages/fast-usdc/README.md index 8d31d5bdf5c..f242d8cc12a 100644 --- a/packages/fast-usdc/README.md +++ b/packages/fast-usdc/README.md @@ -88,12 +88,14 @@ stateDiagram-v2 ```mermaid stateDiagram-v2 - Observed --> Advancing - Observed --> Forwarding:Minted + Observed --> AdvanceSkipped : Risks identified + Observed --> Advancing : No risks, can advance + Observed --> Forwarding : No risks, Mint deposited before advance Forwarding --> Forwarded Advancing --> Advanced Advanced --> Disbursed - AdvanceFailed --> Forwarding + AdvanceSkipped --> Forwarding : Mint deposited + AdvanceFailed --> Forwarding : Mint deposited Advancing --> AdvanceFailed Forwarding --> ForwardFailed ``` diff --git a/packages/fast-usdc/src/constants.js b/packages/fast-usdc/src/constants.js index 28ab7775db7..baeb178df71 100644 --- a/packages/fast-usdc/src/constants.js +++ b/packages/fast-usdc/src/constants.js @@ -12,6 +12,8 @@ export const TxStatus = /** @type {const} */ ({ Advanced: 'ADVANCED', /** IBC transfer failed (timed out) */ AdvanceFailed: 'ADVANCE_FAILED', + /** Advance skipped and waiting for forward */ + AdvanceSkipped: 'ADVANCE_SKIPPED', /** settlement for matching advance received and funds disbursed */ Disbursed: 'DISBURSED', /** fallback: do not collect fees */ @@ -42,5 +44,7 @@ export const PendingTxStatus = /** @type {const} */ ({ AdvanceFailed: 'ADVANCE_FAILED', /** IBC transfer is complete */ Advanced: 'ADVANCED', + /** Advance skipped and waiting for forward */ + AdvanceSkipped: 'ADVANCE_SKIPPED', }); harden(PendingTxStatus); diff --git a/packages/fast-usdc/src/exos/advancer.js b/packages/fast-usdc/src/exos/advancer.js index a96c781f140..9620d93aa9c 100644 --- a/packages/fast-usdc/src/exos/advancer.js +++ b/packages/fast-usdc/src/exos/advancer.js @@ -8,9 +8,9 @@ import { E } from '@endo/far'; import { M, mustMatch } from '@endo/patterns'; import { Fail, q } from '@endo/errors'; import { - CctpTxEvidenceShape, AddressHookShape, EvmHashShape, + EvidenceWithRiskShape, } from '../type-guards.js'; import { makeFeeTools } from '../utils/fees.js'; @@ -22,7 +22,7 @@ import { makeFeeTools } from '../utils/fees.js'; * @import {ZoeTools} from '@agoric/orchestration/src/utils/zoe-tools.js'; * @import {VowTools} from '@agoric/vow'; * @import {Zone} from '@agoric/zone'; - * @import {CctpTxEvidence, AddressHook, EvmHash, FeeConfig, LogFn, NobleAddress} from '../types.js'; + * @import {CctpTxEvidence, AddressHook, EvmHash, FeeConfig, LogFn, NobleAddress, EvidenceWithRisk} from '../types.js'; * @import {StatusManager} from './status-manager.js'; * @import {LiquidityPoolKit} from './liquidity-pool.js'; */ @@ -55,7 +55,7 @@ const AdvancerVowCtxShape = M.splitRecord( /** type guards internal to the AdvancerKit */ const AdvancerKitI = harden({ advancer: M.interface('AdvancerI', { - handleTransactionEvent: M.callWhen(CctpTxEvidenceShape).returns(), + handleTransactionEvent: M.callWhen(EvidenceWithRiskShape).returns(), setIntermediateRecipient: M.call(ChainAddressShape).returns(), }), depositHandler: M.interface('DepositHandlerI', { @@ -137,9 +137,9 @@ export const prepareAdvancerKit = ( * `StatusManager` - so we don't need to concern ourselves with * preserving the vow chain for callers. * - * @param {CctpTxEvidence} evidence + * @param {EvidenceWithRisk} evidenceWithRisk */ - async handleTransactionEvent(evidence) { + async handleTransactionEvent({ evidence, risk }) { await null; try { if (statusManager.hasBeenObserved(evidence)) { @@ -147,6 +147,12 @@ export const prepareAdvancerKit = ( return; } + if (risk.risksIdentified?.length) { + log('risks identified, skipping advance'); + statusManager.skipAdvance(evidence, risk.risksIdentified); + return; + } + const { borrowerFacet, poolAccount, settlementAddress } = this.state; const { recipientAddress } = evidence.aux; diff --git a/packages/fast-usdc/src/exos/operator-kit.js b/packages/fast-usdc/src/exos/operator-kit.js index 7a98af3da5a..eb221c71d92 100644 --- a/packages/fast-usdc/src/exos/operator-kit.js +++ b/packages/fast-usdc/src/exos/operator-kit.js @@ -1,18 +1,18 @@ import { makeTracer } from '@agoric/internal'; import { Fail } from '@endo/errors'; import { M } from '@endo/patterns'; -import { CctpTxEvidenceShape } from '../type-guards.js'; +import { CctpTxEvidenceShape, RiskAssessmentShape } from '../type-guards.js'; const trace = makeTracer('TxOperator'); /** * @import {Zone} from '@agoric/zone'; - * @import {CctpTxEvidence} from '../types.js'; + * @import {CctpTxEvidence, RiskAssessment} from '../types.js'; */ /** * @typedef {object} OperatorPowers - * @property {(evidence: CctpTxEvidence, operatorId: string) => void} attest + * @property {(evidence: CctpTxEvidence, riskAssessment: RiskAssessment, operatorId: string) => void} attest */ /** @@ -31,11 +31,15 @@ const OperatorKitI = { }), invitationMakers: M.interface('InvitationMakers', { - SubmitEvidence: M.call(CctpTxEvidenceShape).returns(M.promise()), + SubmitEvidence: M.call(CctpTxEvidenceShape) + .optional(RiskAssessmentShape) + .returns(M.promise()), }), operator: M.interface('Operator', { - submitEvidence: M.call(CctpTxEvidenceShape).returns(), + submitEvidence: M.call(CctpTxEvidenceShape) + .optional(RiskAssessmentShape) + .returns(), getStatus: M.call().returns(M.record()), }), }; @@ -81,13 +85,14 @@ export const prepareOperatorKit = (zone, staticPowers) => * fluxAggregator contract used for price oracles. * * @param {CctpTxEvidence} evidence + * @param {RiskAssessment} [riskAssessment] * @returns {Promise} */ - async SubmitEvidence(evidence) { + async SubmitEvidence(evidence, riskAssessment) { 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 - operator.submitEvidence(evidence); + operator.submitEvidence(evidence, riskAssessment); return staticPowers.makeInertInvitation( 'evidence was pushed in the invitation maker call', ); @@ -98,12 +103,13 @@ export const prepareOperatorKit = (zone, staticPowers) => * submit evidence from this operator * * @param {CctpTxEvidence} evidence + * @param {RiskAssessment} [riskAssessment] * @returns {void} */ - submitEvidence(evidence) { + submitEvidence(evidence, riskAssessment = {}) { const { state } = this; !state.disabled || Fail`submitEvidence for disabled operator`; - state.powers.attest(evidence, state.operatorId); + state.powers.attest(evidence, riskAssessment, state.operatorId); }, /** @returns {OperatorStatus} */ getStatus() { diff --git a/packages/fast-usdc/src/exos/settler.js b/packages/fast-usdc/src/exos/settler.js index fc6708eedee..7b3b3de2e6f 100644 --- a/packages/fast-usdc/src/exos/settler.js +++ b/packages/fast-usdc/src/exos/settler.js @@ -181,6 +181,7 @@ export const prepareSettler = ( return; case PendingTxStatus.Observed: + case PendingTxStatus.AdvanceSkipped: case PendingTxStatus.AdvanceFailed: return self.forward(found.txHash, nfa, amount, EUD); diff --git a/packages/fast-usdc/src/exos/status-manager.js b/packages/fast-usdc/src/exos/status-manager.js index 6f264fd8997..c261ec599a8 100644 --- a/packages/fast-usdc/src/exos/status-manager.js +++ b/packages/fast-usdc/src/exos/status-manager.js @@ -14,7 +14,7 @@ import { /** * @import {MapStore, SetStore} from '@agoric/store'; * @import {Zone} from '@agoric/zone'; - * @import {CctpTxEvidence, NobleAddress, PendingTx, EvmHash, LogFn, TransactionRecord} from '../types.js'; + * @import {CctpTxEvidence, NobleAddress, PendingTx, EvmHash, LogFn, TransactionRecord, EvidenceWithRisk, RiskAssessment} from '../types.js'; */ /** @@ -146,8 +146,9 @@ export const prepareStatusManager = ( * * @param {CctpTxEvidence} evidence * @param {PendingTxStatus} status + * @param {string[]} [risksIdentified] */ - const initPendingTx = (evidence, status) => { + const initPendingTx = (evidence, status, risksIdentified) => { const { txHash } = evidence; if (seenTxs.has(txHash)) { throw makeError(`Transaction already seen: ${q(txHash)}`); @@ -160,7 +161,9 @@ export const prepareStatusManager = ( harden({ ...evidence, status }), ); publishEvidence(txHash, evidence); - if (status !== PendingTxStatus.Observed) { + if (status === PendingTxStatus.AdvanceSkipped) { + void publishTxnRecord(txHash, harden({ status, risksIdentified })); + } else if (status !== PendingTxStatus.Observed) { // publishEvidence publishes Observed void publishTxnRecord(txHash, harden({ status })); } @@ -194,6 +197,9 @@ export const prepareStatusManager = ( // TODO: naming scheme for transition events advance: M.call(CctpTxEvidenceShape).returns(M.undefined()), advanceOutcome: M.call(M.string(), M.nat(), M.boolean()).returns(), + skipAdvance: M.call(CctpTxEvidenceShape, M.arrayOf(M.string())).returns( + M.undefined(), + ), observe: M.call(CctpTxEvidenceShape).returns(M.undefined()), hasBeenObserved: M.call(CctpTxEvidenceShape).returns(M.boolean()), deleteCompletedTxs: M.call().returns(M.undefined()), @@ -203,6 +209,7 @@ export const prepareStatusManager = ( txHash: EvmHashShape, status: M.or( PendingTxStatus.Advanced, + PendingTxStatus.AdvanceSkipped, PendingTxStatus.AdvanceFailed, PendingTxStatus.Observed, ), @@ -224,7 +231,8 @@ export const prepareStatusManager = ( /** * Add a new transaction with ADVANCING status * - * NB: this acts like observe() but skips recording the OBSERVED state + * NB: this acts like observe() but subsequently records an ADVANCING + * state * * @param {CctpTxEvidence} evidence */ @@ -232,6 +240,23 @@ export const prepareStatusManager = ( initPendingTx(evidence, PendingTxStatus.Advancing); }, + /** + * Add a new transaction with ADVANCE_SKIPPED status + * + * NB: this acts like observe() but subsequently records an + * ADVANCE_SKIPPED state along with risks identified + * + * @param {CctpTxEvidence} evidence + * @param {string[]} risksIdentified + */ + skipAdvance(evidence, risksIdentified) { + initPendingTx( + evidence, + PendingTxStatus.AdvanceSkipped, + risksIdentified, + ); + }, + /** * Record result of ADVANCING * diff --git a/packages/fast-usdc/src/exos/transaction-feed.js b/packages/fast-usdc/src/exos/transaction-feed.js index 231ef4f828d..3adb69bb0f1 100644 --- a/packages/fast-usdc/src/exos/transaction-feed.js +++ b/packages/fast-usdc/src/exos/transaction-feed.js @@ -2,14 +2,15 @@ import { makeTracer } from '@agoric/internal'; import { prepareDurablePublishKit } from '@agoric/notifier'; import { keyEQ, M } from '@endo/patterns'; import { Fail } from '@endo/errors'; -import { CctpTxEvidenceShape } from '../type-guards.js'; +import { CctpTxEvidenceShape, RiskAssessmentShape } from '../type-guards.js'; import { defineInertInvitation } from '../utils/zoe.js'; import { prepareOperatorKit } from './operator-kit.js'; /** * @import {Zone} from '@agoric/zone'; + * @import {MapStore} from '@agoric/store'; * @import {OperatorKit} from './operator-kit.js'; - * @import {CctpTxEvidence} from '../types.js'; + * @import {CctpTxEvidence, EvidenceWithRisk, RiskAssessment} from '../types.js'; */ const trace = makeTracer('TxFeed', true); @@ -19,7 +20,11 @@ export const INVITATION_MAKERS_DESC = 'oracle operator invitation'; const TransactionFeedKitI = harden({ operatorPowers: M.interface('Transaction Feed Admin', { - attest: M.call(CctpTxEvidenceShape, M.string()).returns(), + attest: M.call( + CctpTxEvidenceShape, + RiskAssessmentShape, + M.string(), + ).returns(), }), creator: M.interface('Transaction Feed Creator', { // TODO narrow the return shape to OperatorKit @@ -32,6 +37,22 @@ const TransactionFeedKitI = harden({ }), }); +/** + * @param {MapStore[]} riskStores + * @param {string} txHash + */ +const allRisksIdentified = (riskStores, txHash) => { + /** @type {Set} */ + const setOfRisks = new Set(); + for (const store of riskStores) { + const next = store.get(txHash); + for (const risk of next.risksIdentified ?? []) { + setOfRisks.add(risk); + } + } + return [...setOfRisks.values()].sort(); +}; + /** * @param {Zone} zone * @param {ZCF} zcf @@ -42,7 +63,7 @@ export const prepareTransactionFeedKit = (zone, zcf) => { kinds, 'Transaction Feed', ); - /** @type {PublishKit} */ + /** @type {PublishKit} */ const { publisher, subscriber } = makeDurablePublishKit(); const makeInertInvitation = defineInertInvitation(zcf, 'submitting evidence'); @@ -56,14 +77,12 @@ export const prepareTransactionFeedKit = (zone, zcf) => { TransactionFeedKitI, () => { /** @type {MapStore} */ - const operators = zone.mapStore('operators', { - durable: true, - }); + const operators = zone.mapStore('operators'); /** @type {MapStore>} */ - const pending = zone.mapStore('pending', { - durable: true, - }); - return { operators, pending }; + const pending = zone.mapStore('pending'); + /** @type {MapStore>} */ + const risks = zone.mapStore('risks'); + return { operators, pending, risks }; }, { creator: { @@ -90,7 +109,7 @@ export const prepareTransactionFeedKit = (zone, zcf) => { }, /** @param {string} operatorId */ initOperator(operatorId) { - const { operators, pending } = this.state; + const { operators, pending, risks } = this.state; trace('initOperator', operatorId); const operatorKit = makeOperatorKit( @@ -102,6 +121,7 @@ export const prepareTransactionFeedKit = (zone, zcf) => { operatorId, zone.detached().mapStore('pending evidence'), ); + risks.init(operatorId, zone.detached().mapStore('risk assessments')); return operatorKit; }, @@ -122,11 +142,12 @@ export const prepareTransactionFeedKit = (zone, zcf) => { * NB: the operatorKit is responsible for * * @param {CctpTxEvidence} evidence + * @param {RiskAssessment} riskAssessment * @param {string} operatorId */ - attest(evidence, operatorId) { - const { operators, pending } = this.state; - trace('submitEvidence', operatorId, evidence); + attest(evidence, riskAssessment, operatorId) { + const { operators, pending, risks } = this.state; + trace('attest', operatorId, evidence); // TODO https://github.com/Agoric/agoric-sdk/pull/10720 // TODO validate that it's a valid for Fast USDC before accepting @@ -141,6 +162,9 @@ export const prepareTransactionFeedKit = (zone, zcf) => { trace(`operator ${operatorId} already reported ${txHash}`); } else { pendingStore.init(txHash, evidence); + // accept the risk assessment as well + const riskStore = risks.get(operatorId); + riskStore.init(txHash, riskAssessment); } } @@ -183,12 +207,24 @@ export const prepareTransactionFeedKit = (zone, zcf) => { lastEvidence = next; } - // sufficient agreement, so remove from pending and publish + const riskStores = [...risks.values()].filter(store => + store.has(txHash), + ); + // take the union of risks identified from all operators + const risksIdentified = allRisksIdentified(riskStores, txHash); + + // sufficient agreement, so remove from pending risks, then publish for (const store of found) { store.delete(txHash); } - trace('publishing evidence', evidence); - publisher.publish(evidence); + for (const store of riskStores) { + store.delete(txHash); + } + trace('publishing evidence', evidence, risksIdentified); + publisher.publish({ + evidence, + risk: { risksIdentified }, + }); }, }, public: { diff --git a/packages/fast-usdc/src/fast-usdc.contract.js b/packages/fast-usdc/src/fast-usdc.contract.js index cb3ed9bd8e7..cbac54c5acc 100644 --- a/packages/fast-usdc/src/fast-usdc.contract.js +++ b/packages/fast-usdc/src/fast-usdc.contract.js @@ -38,7 +38,7 @@ const ADDRESSES_BAGGAGE_KEY = 'addresses'; * @import {Marshaller, StorageNode} from '@agoric/internal/src/lib-chainStorage.js' * @import {Zone} from '@agoric/zone'; * @import {OperatorKit} from './exos/operator-kit.js'; - * @import {CctpTxEvidence, FeeConfig} from './types.js'; + * @import {CctpTxEvidence, FeeConfig, RiskAssessment} from './types.js'; */ /** @@ -202,9 +202,10 @@ export const contract = async (zcf, privateArgs, zone, tools) => { * capability is available in the smart-wallet bridge during UI testing. * * @param {CctpTxEvidence} evidence + * @param {RiskAssessment} [risk] */ - makeTestPushInvitation(evidence) { - void advancer.handleTransactionEvent(evidence); + makeTestPushInvitation(evidence, risk = {}) { + void advancer.handleTransactionEvent({ evidence, risk }); return makeTestInvitation(); }, makeDepositInvitation() { @@ -303,9 +304,9 @@ export const contract = async (zcf, privateArgs, zone, tools) => { ); // Connect evidence stream to advancer void observeIteration(subscribeEach(feedKit.public.getEvidenceSubscriber()), { - updateState(evidence) { + updateState(evidenceWithRisk) { try { - void advancer.handleTransactionEvent(evidence); + void advancer.handleTransactionEvent(evidenceWithRisk); } catch (err) { trace('🚨 Error handling transaction event', err); } diff --git a/packages/fast-usdc/src/type-guards.js b/packages/fast-usdc/src/type-guards.js index 05215f43886..0a0c915358f 100644 --- a/packages/fast-usdc/src/type-guards.js +++ b/packages/fast-usdc/src/type-guards.js @@ -6,7 +6,7 @@ import { PendingTxStatus } from './constants.js'; * @import {TypedPattern} from '@agoric/internal'; * @import {FastUsdcTerms} from './fast-usdc.contract.js'; * @import {USDCProposalShapes} from './pool-share-math.js'; - * @import {CctpTxEvidence, FeeConfig, PendingTx, PoolMetrics, ChainPolicy, FeedPolicy, AddressHook, EvmAddress, EvmHash} from './types.js'; + * @import {CctpTxEvidence, FeeConfig, PendingTx, PoolMetrics, ChainPolicy, FeedPolicy, AddressHook, EvmAddress, EvmHash, RiskAssessment, EvidenceWithRisk} from './types.js'; */ /** @@ -49,6 +49,15 @@ export const EvmHashShape = M.string({ }); harden(EvmHashShape); +/** @type {TypedPattern} */ +export const RiskAssessmentShape = M.splitRecord( + {}, + { + risksIdentified: M.arrayOf(M.string()), + }, +); +harden(RiskAssessmentShape); + /** @type {TypedPattern} */ export const CctpTxEvidenceShape = { aux: { @@ -67,6 +76,13 @@ export const CctpTxEvidenceShape = { }; harden(CctpTxEvidenceShape); +/** @type {TypedPattern} */ +export const EvidenceWithRiskShape = { + evidence: CctpTxEvidenceShape, + risk: RiskAssessmentShape, +}; +harden(EvidenceWithRiskShape); + /** @type {TypedPattern} */ // @ts-expect-error TypedPattern not recognized as record export const PendingTxShape = { diff --git a/packages/fast-usdc/src/types.ts b/packages/fast-usdc/src/types.ts index 44f9669613d..433ea135d97 100644 --- a/packages/fast-usdc/src/types.ts +++ b/packages/fast-usdc/src/types.ts @@ -17,6 +17,10 @@ export type NobleAddress = `noble1${string}`; export type EvmChainID = number; export type EvmChainName = string; +export interface RiskAssessment { + risksIdentified?: string[]; +} + export interface CctpTxEvidence { /** from Noble RPC */ aux: { @@ -35,6 +39,11 @@ export interface CctpTxEvidence { txHash: EvmHash; } +export interface EvidenceWithRisk { + evidence: CctpTxEvidence; + risk: RiskAssessment; +} + /** * 'evidence' only available when it's first observed and not in subsequent * updates. @@ -42,6 +51,7 @@ export interface CctpTxEvidence { export interface TransactionRecord extends CopyRecord { evidence?: CctpTxEvidence; split?: RepayAmountKWR; + risksIdentified?: string[]; status: TxStatus; } diff --git a/packages/fast-usdc/test/exos/advancer.test.ts b/packages/fast-usdc/test/exos/advancer.test.ts index 1fba00c8fe3..e8ee9c1a626 100644 --- a/packages/fast-usdc/test/exos/advancer.test.ts +++ b/packages/fast-usdc/test/exos/advancer.test.ts @@ -186,7 +186,7 @@ test('updates status to ADVANCING in happy path', async t => { } = t.context; const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); - void advancer.handleTransactionEvent(evidence); + void advancer.handleTransactionEvent({ evidence, risk: {} }); // pretend borrow succeeded and funds were depositing to the LCA resolveLocalTransferV(); @@ -270,7 +270,7 @@ test('updates status to OBSERVED on insufficient pool funds', async t => { }); const evidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); - void advancer.handleTransactionEvent(evidence); + void advancer.handleTransactionEvent({ evidence, risk: {} }); await eventLoopIteration(); t.deepEqual( @@ -300,7 +300,7 @@ test('updates status to OBSERVED if makeChainAddress fails', async t => { } = t.context; const evidence = MockCctpTxEvidences.AGORIC_UNKNOWN_EUD(); - await advancer.handleTransactionEvent(evidence); + await advancer.handleTransactionEvent({ evidence, risk: {} }); await eventLoopIteration(); t.deepEqual( @@ -330,7 +330,7 @@ test('calls notifyAdvancingResult (AdvancedFailed) on failed transfer', async t } = t.context; const evidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); - void advancer.handleTransactionEvent(evidence); + void advancer.handleTransactionEvent({ evidence, risk: {} }); // pretend borrow and deposit to LCA succeed resolveLocalTransferV(); @@ -382,7 +382,7 @@ test('updates status to OBSERVED if pre-condition checks fail', async t => { const evidence = MockCctpTxEvidences.AGORIC_NO_PARAMS(); - await advancer.handleTransactionEvent(evidence); + await advancer.handleTransactionEvent({ evidence, risk: {} }); await eventLoopIteration(); t.deepEqual( @@ -399,14 +399,17 @@ test('updates status to OBSERVED if pre-condition checks fail', async t => { ]); await advancer.handleTransactionEvent({ - ...MockCctpTxEvidences.AGORIC_NO_PARAMS( - encodeAddressHook(settlementAddress.value, { - EUD: 'osmo1234', - extra: 'value', - }), - ), - txHash: - '0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761799', + evidence: { + ...MockCctpTxEvidences.AGORIC_NO_PARAMS( + encodeAddressHook(settlementAddress.value, { + EUD: 'osmo1234', + extra: 'value', + }), + ), + txHash: + '0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761799', + }, + risk: {}, }); const [, ...remainingLogs] = inspectLogs(); @@ -420,6 +423,37 @@ test('updates status to OBSERVED if pre-condition checks fail', async t => { ]); }); +test('updates status to ADVANCE_SKIPPED if risks identified', async t => { + const { + bootstrap: { storage }, + extensions: { + services: { advancer }, + helpers: { inspectLogs }, + }, + } = t.context; + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + await advancer.handleTransactionEvent({ + evidence, + risk: { risksIdentified: ['TOO_LARGE_AMOUNT'] }, + }); + await eventLoopIteration(); + + t.deepEqual( + storage.getDeserialized(`fun.txns.${evidence.txHash}`), + [ + { evidence, status: PendingTxStatus.Observed }, + { + status: PendingTxStatus.AdvanceSkipped, + risksIdentified: ['TOO_LARGE_AMOUNT'], + }, + ], + 'tx is recorded as ADVANCE_SKIPPED', + ); + + t.deepEqual(inspectLogs(), [['risks identified, skipping advance']]); +}); + test('will not advance same txHash:chainId evidence twice', async t => { const { extensions: { @@ -433,7 +467,7 @@ test('will not advance same txHash:chainId evidence twice', async t => { const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); // First attempt - void advancer.handleTransactionEvent(mockEvidence); + void advancer.handleTransactionEvent({ evidence: mockEvidence, risk: {} }); resolveLocalTransferV(); mockPoolAccount.transferVResolver.resolve(); await eventLoopIteration(); @@ -455,7 +489,7 @@ test('will not advance same txHash:chainId evidence twice', async t => { ]); // Second attempt - void advancer.handleTransactionEvent(mockEvidence); + void advancer.handleTransactionEvent({ evidence: mockEvidence, risk: {} }); await eventLoopIteration(); const [, , ...remainingLogs] = inspectLogs(); t.deepEqual(remainingLogs, [ @@ -477,7 +511,7 @@ test('returns payment to LP if zoeTools.localTransfer fails', async t => { } = t.context; const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); - void advancer.handleTransactionEvent(mockEvidence); + void advancer.handleTransactionEvent({ evidence: mockEvidence, risk: {} }); rejectLocalTransfeferV(); await eventLoopIteration(); @@ -561,7 +595,7 @@ test('alerts if `returnToPool` fallback fails', async t => { }); const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); - void advancer.handleTransactionEvent(mockEvidence); + void advancer.handleTransactionEvent({ evidence: mockEvidence, risk: {} }); rejectLocalTransfeferV(); await eventLoopIteration(); @@ -614,7 +648,7 @@ test('rejects advances to unknown settlementAccount', async t => { }), ); - void advancer.handleTransactionEvent(mockEvidence); + void advancer.handleTransactionEvent({ evidence: mockEvidence, risk: {} }); await eventLoopIteration(); t.deepEqual(inspectLogs(), [ [ diff --git a/packages/fast-usdc/test/exos/settler.test.ts b/packages/fast-usdc/test/exos/settler.test.ts index bedcf3f6065..5a8d9928385 100644 --- a/packages/fast-usdc/test/exos/settler.test.ts +++ b/packages/fast-usdc/test/exos/settler.test.ts @@ -110,6 +110,18 @@ const makeTestContext = async t => { return cctpTxEvidence; }, + skipAdvance: (risksIdentified: string[], evidence?: CctpTxEvidence) => { + const cctpTxEvidence: CctpTxEvidence = { + ...MockCctpTxEvidences.AGORIC_PLUS_OSMO(), + ...evidence, + }; + t.log('Mock CCTP Evidence:', cctpTxEvidence); + t.log('Mark as `ADVANCE_SKIPPED`'); + statusManager.skipAdvance(cctpTxEvidence, risksIdentified ?? []); + + return cctpTxEvidence; + }, + observe: (evidence?: CctpTxEvidence) => { const cctpTxEvidence: CctpTxEvidence = { ...MockCctpTxEvidences.AGORIC_PLUS_OSMO(), @@ -325,6 +337,83 @@ test('slow path: forward to EUD; remove pending tx', async t => { t.is(storage.data.get(`fun.txns.${cctpTxEvidence.txHash}`), undefined); }); +test('skip advance: forward to EUD; remove pending tx', async t => { + const { + common, + makeSettler, + statusManager, + defaultSettlerParams, + repayer, + simulate, + accounts, + peekCalls, + } = t.context; + const { usdc } = common.brands; + + const settler = makeSettler({ + repayer, + settlementAccount: accounts.settlement.account, + ...defaultSettlerParams, + }); + + const cctpTxEvidence = simulate.skipAdvance(['TOO_LARGE_AMOUNT']); + t.deepEqual( + statusManager.lookupPending( + cctpTxEvidence.tx.forwardingAddress, + cctpTxEvidence.tx.amount, + ), + [{ ...cctpTxEvidence, status: PendingTxStatus.AdvanceSkipped }], + 'statusManager shows this tx is skipped', + ); + + t.log('Simulate incoming IBC settlement'); + void settler.tap.receiveUpcall(MockVTransferEvents.AGORIC_PLUS_OSMO()); + await eventLoopIteration(); + + t.log('funds are forwarded; no interaction with LP'); + t.deepEqual(peekCalls(), []); + t.deepEqual(accounts.settlement.callLog, [ + [ + 'transfer', + { + chainId: 'osmosis-1', + encoding: 'bech32', + value: 'osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + }, + usdc.units(150), + { + forwardOpts: { + intermediateRecipient: { + chainId: 'noble-1', + encoding: 'bech32', + value: 'noble1test', + }, + }, + }, + ], + ]); + + t.deepEqual( + statusManager.lookupPending( + cctpTxEvidence.tx.forwardingAddress, + cctpTxEvidence.tx.amount, + ), + [], + 'FORWARDED entry removed from StatusManger', + ); + const { storage } = t.context; + t.deepEqual(storage.getDeserialized(`fun.txns.${cctpTxEvidence.txHash}`), [ + { evidence: cctpTxEvidence, status: 'OBSERVED' }, + { status: 'ADVANCE_SKIPPED', risksIdentified: ['TOO_LARGE_AMOUNT'] }, + { status: 'FORWARDED' }, + ]); + + // Check deletion of FORWARDED transactions + statusManager.deleteCompletedTxs(); + await eventLoopIteration(); + t.is(storage.data.get(`fun.txns.${cctpTxEvidence.txHash}`), undefined); +}); + test('Settlement for unknown transaction', async t => { const { common, diff --git a/packages/fast-usdc/test/exos/status-manager.test.ts b/packages/fast-usdc/test/exos/status-manager.test.ts index 7322fe0aeea..2c76fefa205 100644 --- a/packages/fast-usdc/test/exos/status-manager.test.ts +++ b/packages/fast-usdc/test/exos/status-manager.test.ts @@ -64,6 +64,34 @@ test('ADVANCED transactions are published to vstorage', async t => { ]); }); +test('skipAdvance creates new entry with ADVANCE_SKIPPED status', t => { + const { statusManager } = t.context; + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + statusManager.skipAdvance(evidence, ['RISK1']); + + const entries = statusManager.lookupPending( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + + t.is(entries[0]?.status, PendingTxStatus.AdvanceSkipped); +}); + +test('ADVANCE_SKIPPED transactions are published to vstorage', async t => { + const { statusManager } = t.context; + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + statusManager.skipAdvance(evidence, ['RISK1']); + await eventLoopIteration(); + + const { storage } = t.context; + t.deepEqual(storage.getDeserialized(`fun.txns.${evidence.txHash}`), [ + { evidence, status: 'OBSERVED' }, + { status: 'ADVANCE_SKIPPED', risksIdentified: ['RISK1'] }, + ]); +}); + test('observe creates new entry with OBSERVED status', t => { const { statusManager } = t.context; const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); diff --git a/packages/fast-usdc/test/exos/transaction-feed.test.ts b/packages/fast-usdc/test/exos/transaction-feed.test.ts index 8f5084af0ea..e3cb8df299b 100644 --- a/packages/fast-usdc/test/exos/transaction-feed.test.ts +++ b/packages/fast-usdc/test/exos/transaction-feed.test.ts @@ -55,7 +55,7 @@ test('happy aggregation', async t => { // Publishes with 2 of 3 const accepted = await evidenceSubscriber.getUpdateSince(0); t.deepEqual(accepted, { - value: e1, + value: { evidence: e1, risk: { risksIdentified: [] } }, updateCount: 1n, }); @@ -75,6 +75,78 @@ test('happy aggregation', async t => { }); }); +test('takes union of risk assessments', async t => { + const feedKit = makeFeedKit(); + const evidenceSubscriber = feedKit.public.getEvidenceSubscriber(); + + const { op1, op2 } = await makeOperators(feedKit); + + const e1 = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + op1.operator.submitEvidence(e1, { risksIdentified: ['RISK1'] }); + op2.operator.submitEvidence(e1, { risksIdentified: ['RISK2'] }); + + // Publishes with 2 of 3 + const accepted = await evidenceSubscriber.getUpdateSince(0); + t.deepEqual(accepted, { + value: { evidence: e1, risk: { risksIdentified: ['RISK1', 'RISK2'] } }, + updateCount: 1n, + }); +}); + +test('takes union of risk assessments pt. 2', async t => { + const feedKit = makeFeedKit(); + const evidenceSubscriber = feedKit.public.getEvidenceSubscriber(); + + const { op1, op2 } = await makeOperators(feedKit); + + const e1 = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + op1.operator.submitEvidence(e1, { risksIdentified: ['RISK1'] }); + op2.operator.submitEvidence(e1); + + // Publishes with 2 of 3 + const accepted = await evidenceSubscriber.getUpdateSince(0); + t.deepEqual(accepted, { + value: { evidence: e1, risk: { risksIdentified: ['RISK1'] } }, + updateCount: 1n, + }); +}); + +test('takes union of risk assessments pt. 3', async t => { + const feedKit = makeFeedKit(); + const evidenceSubscriber = feedKit.public.getEvidenceSubscriber(); + + const { op1, op2 } = await makeOperators(feedKit); + + const e1 = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + op1.operator.submitEvidence(e1, { risksIdentified: ['RISK1'] }); + op2.operator.submitEvidence(e1, { risksIdentified: ['RISK1'] }); + + // Publishes with 2 of 3 + const accepted = await evidenceSubscriber.getUpdateSince(0); + t.deepEqual(accepted, { + value: { evidence: e1, risk: { risksIdentified: ['RISK1'] } }, + updateCount: 1n, + }); +}); + +test('takes union of risk assessments pt. 4', async t => { + const feedKit = makeFeedKit(); + const evidenceSubscriber = feedKit.public.getEvidenceSubscriber(); + + const { op1, op2 } = await makeOperators(feedKit); + + 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: e1, risk: { risksIdentified: [] } }, + updateCount: 1n, + }); +}); + test('disagreement', async t => { const feedKit = makeFeedKit(); const { op1, op2 } = await makeOperators(feedKit); diff --git a/packages/fast-usdc/test/fast-usdc.contract.test.ts b/packages/fast-usdc/test/fast-usdc.contract.test.ts index 6187f5ddc69..9d333a40531 100644 --- a/packages/fast-usdc/test/fast-usdc.contract.test.ts +++ b/packages/fast-usdc/test/fast-usdc.contract.test.ts @@ -34,7 +34,7 @@ import { makePromiseKit } from '@endo/promise-kit'; import path from 'path'; 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'; +import { CctpTxEvidenceShape, 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'; @@ -214,7 +214,7 @@ const purseOf = const makeOracleOperator = async ( opInv: Invitation, - txSubscriber: Subscriber, + txSubscriber: Subscriber, zoe: ZoeService, t: ExecutionContext, ) => { @@ -239,13 +239,16 @@ const makeOracleOperator = async ( return harden({ watch: () => { void observeIteration(subscribeEach(txSubscriber), { - updateState: tx => { + updateState: ({ evidence, isRisk }) => { if (!active) { return; } // KLUDGE: tx wouldn't include aux. OCW looks it up return E.when( - E(invitationMakers).SubmitEvidence(tx), + E(invitationMakers).SubmitEvidence( + evidence, + isRisk ? { risksIdentified: ['RISK1'] } : {}, + ), inv => E.when(E(E(zoe).offer(inv)).getOfferResult(), res => { t.is(res, 'inert; nothing should be expected from this offer'); @@ -368,20 +371,29 @@ const makeEVM = (template = MockCctpTxEvidences.AGORIC_PLUS_OSMO()) => { return tx; }; - const txPub = makePublishKit(); + const txPub = makePublishKit(); return harden({ cctp: { makeTx }, txPub }); }; +/** + * We pass around evidence along with a flag to indicate whether it should be + * treated as risky for testing purposes. + */ +interface TxWithRisk { + evidence: CctpTxEvidence; + isRisk: boolean; +} + const makeCustomer = ( who: string, cctp: ReturnType['cctp'], - txPublisher: Publisher, + txPublisher: Publisher, feeConfig: FeeConfig, // TODO: get from vstorage (or at least: a subscriber) ) => { const USDC = feeConfig.flat.brand; const feeTools = makeFeeTools(feeConfig); - const sent = [] as CctpTxEvidence[]; + const sent = [] as TxWithRisk[]; const me = harden({ checkPoolAvailable: async ( @@ -399,6 +411,7 @@ const makeCustomer = ( t: ExecutionContext, amount: bigint, EUD: string, + isRisk = false, ) => { const { storage } = t.context.common.bootstrap; const accountsData = storage.data.get('fun'); @@ -410,8 +423,8 @@ const makeCustomer = ( // "cctp" here has some noble stuff mixed in. const tx = cctp.makeTx(amount, recipientAddress); t.log(who, 'signs CCTP for', amount, 'uusdc w/EUD:', EUD); - txPublisher.publish(tx); - sent.push(tx); + txPublisher.publish({ evidence: tx, isRisk }); + sent.push({ evidence: tx, isRisk }); await eventLoopIteration(); return tx; }, @@ -420,8 +433,9 @@ const makeCustomer = ( { bank = [] as any[], local = [] as any[] } = {}, forward?: unknown, ) => { - const evidence = sent.shift(); - if (!evidence) throw t.fail('nothing sent'); + const next = sent.shift(); + if (!next) throw t.fail('nothing sent'); + const { evidence } = next; // C3 - Contract MUST calculate AdvanceAmount by ... // Mostly, see unit tests for calculateAdvance, calculateSplit @@ -541,6 +555,27 @@ test.serial('C25 - LPs can deposit USDC', async t => { t.deepEqual(poolBalance, make(usdc.brand, 250_000_000n + balance0.value)); }); +test.serial('Contract skips advance when risks identified', async t => { + const { + common: { + commonPrivateArgs: { feeConfig }, + utils: { transmitTransferAck }, + }, + evm: { cctp, txPub }, + startKit: { metricsSub }, + bridges: { snapshot, since }, + mint, + } = t.context; + const custEmpty = makeCustomer('Skippy', cctp, txPub.publisher, feeConfig); + const bridgePos = snapshot(); + const sent = await custEmpty.sendFast(t, 1_000_000n, 'osmo123', true); + const bridgeTraffic = since(bridgePos); + await mint(sent); + custEmpty.checkSent(t, bridgeTraffic, 'forward'); + t.log('No advancement, just settlement'); + await transmitTransferAck(); // ack IBC transfer for forward +}); + test.serial('STORY01: advancing happy path for 100 USDC', async t => { const { common: { diff --git a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md index 99f90954dbe..52a2897ceb7 100644 --- a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md +++ b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md @@ -612,6 +612,7 @@ Generated by [AVA](https://avajs.dev). }, }, pending: {}, + risks: {}, vstorage: { 'Durable Publish Kit_kindHandle': 'Alleged: kind', Recorder_kindHandle: 'Alleged: kind', diff --git a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap index b4eafafc079..2554e4fe33d 100644 Binary files a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap and b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap differ