From ff6737a574e4a2efccda226780ed09e3fb4076b3 Mon Sep 17 00:00:00 2001 From: Samuel Siegart Date: Thu, 19 Dec 2024 21:00:23 -0800 Subject: [PATCH] feat(fast-usdc): support risk assessment arg --- .../boot/test/fast-usdc/fast-usdc.test.ts | 50 ++++++++++ .../fast-usdc/snapshots/fast-usdc.test.ts.md | 25 +++++ .../snapshots/fast-usdc.test.ts.snap | Bin 2218 -> 2399 bytes packages/fast-usdc/README.md | 8 +- packages/fast-usdc/src/constants.js | 4 + packages/fast-usdc/src/exos/advancer.js | 16 +++- packages/fast-usdc/src/exos/operator-kit.js | 24 +++-- packages/fast-usdc/src/exos/settler.js | 1 + packages/fast-usdc/src/exos/status-manager.js | 33 ++++++- .../fast-usdc/src/exos/transaction-feed.js | 72 ++++++++++---- packages/fast-usdc/src/fast-usdc.contract.js | 11 ++- packages/fast-usdc/src/type-guards.js | 18 +++- packages/fast-usdc/src/types.ts | 10 ++ packages/fast-usdc/test/exos/advancer.test.ts | 70 ++++++++++---- packages/fast-usdc/test/exos/settler.test.ts | 89 ++++++++++++++++++ .../test/exos/status-manager.test.ts | 28 ++++++ .../test/exos/transaction-feed.test.ts | 74 ++++++++++++++- .../fast-usdc/test/fast-usdc.contract.test.ts | 57 ++++++++--- .../snapshots/fast-usdc.contract.test.ts.md | 1 + .../snapshots/fast-usdc.contract.test.ts.snap | Bin 5809 -> 5832 bytes 20 files changed, 516 insertions(+), 75 deletions(-) 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 0098b4788bfb2c84ca53df4b055872bf5e179aab..8528f3eda7ceb0a0e98939df069cb4dddc414925 100644 GIT binary patch literal 2399 zcmV-l383~tRzVo1=+8>Ju00000000B!SzC-7R~i1!%-HMfCfQw5BtmI2atck+UTkmd^`;eyg9=J(BRiJ>> z0xALtQ6T|UB@oQ3xrY^X?<_Yf760nNd^6&@{)=n_#YA@$^?KEfITESP9izi zrJIB_pGHSVL54vH$Os|#H;q&VU=_fpLWWA%PyyMAJw(ABL>ReU#*9}(as0;Ck+F$MUN5^@>oaH)loypsyS zoEjC(%nMOH>RM)rmdGdO)9Kuy8Omrr zmC3T1L$e|8dksTWm-s4kPBG78PKmjlzLRq^GYK=9n4ilV+1Ye5mCQ0)OeYtz*=%|~ zkjzf^*J-uNsJn9SR1*~L+5!b#PFJZn zCTPFDWti~vz?kr^?wF95dCMrzwICt#GJszJcniQ70Veyyf+oxDWT4!38r%^sHN-h8 zn3S#sq*~FYLcYBkcso*|8yBc+(Slt!`9aX(T0>HI7$fT63GfdByfa+8AvlN;HLU>m zDZsg~YOF)GrT~`};EAy6jt|A2nrvIFpA8r}2@-3ckmxwsle2ABV&>sG{7N?+ z^Y4E*$l*6PLJoD<>A?+Km|Als2T3MqvB2#?0Q(yRQ03g#D@?eS;q|6~$c0-*0nZFf z0sFd8fS>xQdge0D=UXH&^eTYY0qh{ay9tmdK#>3+?@tC%x&5vMuzQ?2My*m{E;El( zn>t1v-hUWS8TQK5We;*!l%?#=AoL3^sC|^XE?@Vg^gsYe$E7sipqpci)m2UhRCY;~ zvz*&4@n#T;rT84H(oIj!@^uAxUID({pIKg2fY%j34MSVBLv^1D+^qulg;j?;R3B1- zkEpGBNH}JI=oW+Rx6BYQm3j8^&x@#Nc+dUwJ1mR~+>%kSK)`#mJzlq3C z-h}P5H(`5qaNAf19c~M_F`|A!176U87yEEyjHtiWfInyeBE7qqF_1{N@7AF9M+Rn* zmhIWWZDSqn+1}QK`1QN!qlDMaLy-peywaC@0(iF#`}iDlelf2m! z-Mpks#wX$~t8l?W^HAp1>9)k%X2&1E|MaXpDtFfNU3ONi4S=KVNZlse(L=T~AgdGo zCi!|lrtit_^Lf2_IW;w`>$-lL3wBuV0)r3N>pN!{!e8_cO>|Xiph`nUdh{n2W6Fh)Zku) zd-qQI#*>XkVRF?HQy0DZaBqiB#;wp*=IJ%hG<0gN`A5RewuNMF*?IZpfzQhiby+{L z;doPt4I`T@7@1@uUC1P8YUYqJYors@NX}-H8CEdNbUKqXXEGVHm@O1%3M@TWEY4(S z)9KV~COMl($sAv6owb!10e1I4SCbG-M?FDBt@VGvj@fB?&U4NCl!`cx;^^jgWjrDZQow{dYL)8Yk8}l zZkfJ$(PCzo|L(pT*mPSqA>*Xz6cb;ElJu%7^*?AZ}fVO0)~Ewa@z4(wQ?W59H1rUs%bVI&=Q? R(*JMK{|kKMIx?^y002Lgq^tk{ literal 2218 zcmV;b2vzq%RzV*U(0|lC_oI+c4zVrHGpD#tIjxPyO zX&Q1#D%yiE-W}VUTkkl#dPMTn$9h)NYgJRpI1Kq@@&PXej}1*8^G zC3vAi0;)K&lb$r ztBhHClRLJ#tqZPi_=0=1#%$lEO~1~CfAzX$Q9(bhysTmZ!H1-XG6x_B-~dU^l0?b# z=r$ppr~Ui)gG_=DkSRhw)NxW7fOP<0h&d{8M+Iar4iE)*5EUdbOYUm6Dvs^fnKfUf zzBun&=6q238CkeYb0bJXf;`$U0Q?%jTL2bFJ6H1I$|YtBqIMr=W@bR95v15h$T@;; zx@ogcZC3`jnGdo*D5hh)!{rr+m1^9x&2pW(E^|&<9pj`Kwq$Zf%xzk_t?&l5-8Nn$ znjFk41h^WnQ|qgf$U2{k)R`)I6ZLzpD!I*q>Pqx zg(6!#vJ{Jcr)i4jIlj)^hnVj(x5m8w*v9hWV%9RUE6ZiGxRf_?Mv>8K-Z)+?7V|6F ze6esOcRX7x#bQs$DeDZZ`i&cikH5*?s_iwXu(|8E$6sL%tuSZnU*}zmd3w70nx*G- zQD=I3z}uGAU2ZYm_H~OntQL$_uyO);jk(ODj!s=mm-S@)CNu4-ZH7zC4i+5uB=!XL zg?@f@r7V}={N%Yh)7chnG##c_n8P=9+i_aH@My410}tP-Sz1T&K+@qu#~)<~yWt*A6M@a=2>4 zIYIl~E#rh|#^!|g4d#ThtXpPzzDo&-R{;DLz}o<32#^^~3z}SR4@Q>TUX#1Rqoz1Z z1(Vi|h*hgPRLItAk=CgO-MT>D6O_UCU(q_Y-tdBb+8;GQ(<>W*=b z+iEZ`G0@v7diuy?0lz)`T-4?(|~f^ZK^MWr2&^T;A$MY zcb8fFvNqPZDMob2el(sl$whpBHlD9)^i0+-$INViqgnObcZEr=XUvNR4W8CxjrZ26BBSbnYt>w1doM0@3oQ^=ICFBg} zPS?C0rDAD5%bIlCm!o_`0bWpm?~PWL*A(Cl1yJMEmh6KbRDruy;QlytvJd*G3Vcch zHsjEKI{Ul|e6h{1!{{tU)Ss)suTQG{A73un( z8Q(Y6w>`VFJ0QXOT@6^m@71A1TYFv|sXYjXA$s)<0@rZrPSCwu7WP+v2x} z=G@)Z9BP`p%uCr62pCdI)H156R}bXz8-2>DL% zsL01Z29K(I`=+Hx00lBkrBXyK5#S^No{0gqIPiG_e2oBa$AA-YU_t?=6ku8D1x}6> z$anP%r0my<683hG&jSNQmCD-=%SB(olaY3%<1Y0IO{VZ9M)j2*e&ckoTCBNY87s6cYdgc(`m`6 zUNXX|zQct-hVxV7J1d8j!&lM{H>uOUe@Y)sFW40`)8w95uuUot`rL7tmoXN&X9c^w zg)3|64d(each}NK*V4A>=aw?YT6%6Ry@M}v*JWnFcc&}~GrJ9$rJMxUxi5ynj$|dQ z&4bdq^X^I$b4_m9ZcW;n9v6I3wu`NE+f620eQW(4&QON?Gq*PR&p1x~`w*f*sWd(BNa?`YxHK2qyg#a|1zbR%t_I#NbkM5Y%=B^BT;u zso06CQ;$V93YBgBfsj2GxM$Xx++o<Gs^7C|i~7S+(>*fby>gX)+T?zN`}bu6 z=S+K}kXg6I{3So^?(MLX@fviU`FhK@Or2UA!I7|k+d>Mr?7aNS*yrU(2h5+?a{c-2 zmRU3^X2HnjD}^l0EgmtK%zT!b#!}HJu!?Er^993NEEKG2u~J>Eu>5khx>#Jw=W|O1 zV=0@HHNM_GYbz-N>>GWqCNY{$`htp9_y2%BrIib%)8$jAPqz2Ne;v6W4*E0X8s@m1 s=~hD*9(8?chV$8Ka#!E1+h#p-FK-8XQenHb|Ls2c--S_@2D=*o00@auLjV8( 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 b4eafafc0799a4cc798735079af6b348f1c0f616..2554e4fe33dcd3587803f69cb4930a38b3ca35ad 100644 GIT binary patch literal 5832 zcmV;(7B}fZRzVc&ZToPzc^4I8p>x6~U*A;L#%ZVG&do!@gocREa36@pHxS#bWqX zF{~(oXbF6?1pavD|7%L&mQwhBDa@V$+6?&c47hg&JUs*QXTtKCFgO!#JoEE|GvVo( z@a9aYE`!Bo&{hUY8C+QgA1@Qk84%4WeXtDvu?z~!p|>19RSqwdLv01vD`0blAmRg} zh~j|?7(N64hYFw))>py@D&g_UOlscENS(V(=n(a-q2X|}HPyv(?bmY!dORE*h^V?2 zO#$lC>`bH}r=~5OurjPgbG4wcb2MrYd!r{~%E*YNA(kPbL6#s@V_sT-1U3f7zg3P8 zjH)f#K##7))UB$nBtn!p22q4D&fdzOie<%&5-_xcCu58Z6DQx+OWD?@@MI&VdYk3B=w~>ZQ4my8QQ9h z7)@aG$r)k;wU33hTpMUsnkYH2I?Zc0$IO$aO4aqL!9ka^JEr!BPwmnS+%{!cHGqF> z0?z1*DbY}e8r6o4vggcYMo8Wi?bnj6_9s(WWl)Polf8T#aC7xIpfHq_nNg{E5ltH! z9cfR=&9zY$n)vb7XsA7HM6Qw+j7iF;C9REI5%GeKrkc(t!?$IMHqCKW6x^^DXLy|C2^bo<~5FZD0s%mHw#u$s0V6Fn@NdlzG z)Mu$o5_|Tx?NN0-lDl^ALMBYAn$WEv{)RY_=;*^_bDEMnmD~z^+qqLGa9k)minQX(81lqPRy@ zqcXod*<;Qo;W~3;8WTyVT^k;WsJa?5gjLvVcbhN;!{KPsJ5QUwmTF_QPZEAUYr?f} z`D~NbBgu*z12t}6xUR*N0X6FeV`aM*AJ*bc+s!(g#_Ha*QHxmH zH)P0MwnYmnk+XCxr0QdZ<=BeEImlSn?o{>I>BJuKeAdv7Swu)-sUdS6 zoq-l(M|Vsc4#(9!iXI%yz30r^t)1Ky)zw&3iNw#Nr_>sHd-ZT6Zp}%*H?{Y?n7LxY z3bVwPS62fU)d)MJd~t`Ax2^^{Qn13-SWG!>D@grRSy`DFgBc=&sUYY~%&4EkmU{f6&uYxt-*28b=p<c@UciADIUaits_H`Mxs`z9+&W(gt}( z15`D@vIf}P0HFrBx&iKJ5U9ffHM3ODS~}L9MzeLNv9c4HWwyLW8sM9v@{G z|E&i2uLhXYC=RI=~JTKyI5^ zONnqiYb|D{%`=v@#x^!p))DRtuIk(n@VB;hc>SGrXQ$oe@p?7{I$K?VR?h~n(-&}i zy@5cRv(4^vI6QuTU_*!B;q36YwR&9vkI&m0NY%5#W?M6VJgTRwR$;TnP2W@1N$jsp zpEK$(&nSL*zB!9wNNaW-2^zq`(Y|K4fvQ37<+x zO5(0J;bx8Fl6H+@9$R3}nWQjrH5gO%F@xUD4$))bP|BjkO{N4v8ZtVprT~XcfLTE` zqQ>>GBA9ZA2{YT&Dj9Wu)�GgED31UuuSuXR8TRJI2o9{TnYat4qN$ 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