From 65ca559ed9bf46f5be4fdbd6123587e4a12c370d Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Thu, 19 Dec 2024 16:38:05 -0500 Subject: [PATCH] feat: report after mint results in forward - Advancer calls `forwardIfMinted` to check for an early matching mint - if found, no Advance occurs and the minted funds are forwarded --- packages/fast-usdc/src/exos/advancer.js | 42 +++++++-- packages/fast-usdc/src/exos/settler.js | 33 ++++++- packages/fast-usdc/test/exos/advancer.test.ts | 62 ++++++++++++- packages/fast-usdc/test/exos/settler.test.ts | 87 +++++++++++++++++++ .../fast-usdc/test/fast-usdc.contract.test.ts | 81 ++++++++++++++--- 5 files changed, 281 insertions(+), 24 deletions(-) diff --git a/packages/fast-usdc/src/exos/advancer.js b/packages/fast-usdc/src/exos/advancer.js index a96c781f140..5069705797b 100644 --- a/packages/fast-usdc/src/exos/advancer.js +++ b/packages/fast-usdc/src/exos/advancer.js @@ -63,7 +63,6 @@ const AdvancerKitI = harden({ onRejected: M.call(M.error(), AdvancerVowCtxShape).returns(), }), transferHandler: M.interface('TransferHandlerI', { - // TODO confirm undefined, and not bigint (sequence) onFulfilled: M.call(M.undefined(), AdvancerVowCtxShape).returns( M.undefined(), ), @@ -146,9 +145,7 @@ export const prepareAdvancerKit = ( log('txHash already seen:', evidence.txHash); return; } - - const { borrowerFacet, poolAccount, settlementAddress } = - this.state; + const { settlementAddress } = this.state; const { recipientAddress } = evidence.aux; const decoded = decodeAddressHook(recipientAddress); mustMatch(decoded, AddressHookShape); @@ -161,6 +158,26 @@ export const prepareAdvancerKit = ( const destination = chainHub.makeChainAddress(EUD); const fullAmount = toAmount(evidence.tx.amount); + const { + tx: { forwardingAddress }, + txHash, + } = evidence; + + const { borrowerFacet, notifyFacet, poolAccount } = this.state; + if ( + notifyFacet.forwardIfMinted( + destination, + forwardingAddress, + fullAmount, + txHash, + ) + ) { + // settlement already received; tx will Forward. + // do not add to `pendingSettleTxs` by calling `.observe()` + log('⚠️ minted before Observed'); + return; + } + // throws if requested does not exceed fees const advanceAmount = feeTools.calculateAdvance(fullAmount); @@ -183,7 +200,7 @@ export const prepareAdvancerKit = ( forwardingAddress: evidence.tx.forwardingAddress, fullAmount, tmpSeat, - txHash: evidence.txHash, + txHash, }); } catch (error) { log('Advancer error:', error); @@ -202,7 +219,13 @@ export const prepareAdvancerKit = ( */ onFulfilled(result, ctx) { const { poolAccount, intermediateRecipient } = this.state; - const { destination, advanceAmount, ...detail } = ctx; + const { + destination, + advanceAmount, + // eslint-disable-next-line no-unused-vars + tmpSeat, + ...detail + } = ctx; const transferV = E(poolAccount).transfer( destination, { denom: usdc.denom, value: advanceAmount.value }, @@ -267,7 +290,12 @@ export const prepareAdvancerKit = ( onRejected(error, ctx) { const { notifyFacet } = this.state; log('Advance transfer rejected', error); - notifyFacet.notifyAdvancingResult(ctx, false); + const { + // eslint-disable-next-line no-unused-vars + advanceAmount, + ...restCtx + } = ctx; + notifyFacet.notifyAdvancingResult(restCtx, false); }, }, }, diff --git a/packages/fast-usdc/src/exos/settler.js b/packages/fast-usdc/src/exos/settler.js index 9b9d2925861..a2382cdc38a 100644 --- a/packages/fast-usdc/src/exos/settler.js +++ b/packages/fast-usdc/src/exos/settler.js @@ -81,6 +81,9 @@ export const prepareSettler = ( makeAdvanceDetailsShape(USDC), M.boolean(), ).returns(), + forwardIfMinted: M.call( + ...Object.values(makeAdvanceDetailsShape(USDC)), + ).returns(M.boolean()), }), self: M.interface('SettlerSelfI', { disburse: M.call(EvmHashShape, M.nat()).returns(M.promise()), @@ -189,7 +192,10 @@ export const prepareSettler = ( case undefined: default: - log('⚠️ tap: no status for ', nfa, amount); + log('⚠️ tap: minted before observed', nfa, amount); + // XXX consider capturing in vstorage + // we would need a new key, as this does not have a txHash + this.state.mintedEarly.add(makeMintedEarlyKey(nfa, amount)); } }, }, @@ -230,6 +236,31 @@ export const prepareSettler = ( statusManager.advanceOutcome(forwardingAddress, fullValue, success); } }, + /** + * @param {ChainAddress} destination + * @param {NobleAddress} forwardingAddress + * @param {Amount<'nat'>} fullAmount + * @param {EvmHash} txHash + * @returns {boolean} + * @throws {Error} if minted early, so advancer doesn't advance + */ + forwardIfMinted(destination, forwardingAddress, fullAmount, txHash) { + const { value: fullValue } = fullAmount; + const key = makeMintedEarlyKey(forwardingAddress, fullValue); + const { mintedEarly } = this.state; + if (mintedEarly.has(key)) { + log( + 'matched minted early key, initiating forward', + forwardingAddress, + fullValue, + ); + mintedEarly.delete(key); + // TODO: does not write `OBSERVED` to vstorage + void this.facets.self.forward(txHash, fullValue, destination.value); + return true; + } + return false; + }, }, self: { /** diff --git a/packages/fast-usdc/test/exos/advancer.test.ts b/packages/fast-usdc/test/exos/advancer.test.ts index 1fba00c8fe3..8a23b4fe117 100644 --- a/packages/fast-usdc/test/exos/advancer.test.ts +++ b/packages/fast-usdc/test/exos/advancer.test.ts @@ -9,13 +9,17 @@ import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; import { denomHash } from '@agoric/orchestration'; import fetchedChainInfo from '@agoric/orchestration/src/fetched-chain-info.js'; import { type ZoeTools } from '@agoric/orchestration/src/utils/zoe-tools.js'; -import { q } from '@endo/errors'; +import { Fail, q } from '@endo/errors'; import { Far } from '@endo/pass-style'; import type { TestFn } from 'ava'; import { makeTracer } from '@agoric/internal'; +import { M, mustMatch } from '@endo/patterns'; import { PendingTxStatus } from '../../src/constants.js'; import { prepareAdvancer } from '../../src/exos/advancer.js'; -import type { SettlerKit } from '../../src/exos/settler.js'; +import { + makeAdvanceDetailsShape, + type SettlerKit, +} from '../../src/exos/settler.js'; import { prepareStatusManager } from '../../src/exos/status-manager.js'; import type { LiquidityPoolKit } from '../../src/types.js'; import { makeFeeTools } from '../../src/utils/fees.js'; @@ -108,8 +112,19 @@ const createTestExtensions = (t, common: CommonSetup) => { const mockNotifyF = Far('Settler Notify Facet', { notifyAdvancingResult: (...args: NotifyArgs) => { trace('Settler.notifyAdvancingResult called with', args); + const [advanceDetails, success] = args; + mustMatch(harden(advanceDetails), makeAdvanceDetailsShape(usdc.brand)); + mustMatch(success, M.boolean()); notifyAdvancingResultCalls.push(args); }, + // assume this never returns true for most tests + forwardIfMinted: (...args) => { + mustMatch( + harden(args), + harden([...Object.values(makeAdvanceDetailsShape(usdc.brand))]), + ); + return false; + }, }); const mockBorrowerFacetCalls: { @@ -361,7 +376,6 @@ test('calls notifyAdvancingResult (AdvancedFailed) on failed transfer', async t txHash: evidence.txHash, forwardingAddress: evidence.tx.forwardingAddress, fullAmount: usdc.make(evidence.tx.amount), - advanceAmount: feeTools.calculateAdvance(usdc.make(evidence.tx.amount)), destination: { value: decodeAddressHook(evidence.aux.recipientAddress).query.EUD, }, @@ -625,3 +639,45 @@ test('rejects advances to unknown settlementAccount', async t => { ], ]); }); + +test('no status update if `forwardIfMinted` returns true', async t => { + const { + brands: { usdc }, + bootstrap: { storage }, + extensions: { + services: { makeAdvancer }, + helpers: { inspectLogs }, + mocks: { mockPoolAccount, mockBorrowerF }, + }, + } = t.context; + + const mockNotifyF = Far('Settler Notify Facet', { + notifyAdvancingResult: () => {}, + forwardIfMinted: (destination, forwardingAddress, fullAmount, txHash) => { + return true; + }, + }); + + const advancer = makeAdvancer({ + borrowerFacet: mockBorrowerF, + notifyFacet: mockNotifyF, + poolAccount: mockPoolAccount.account, + intermediateRecipient, + settlementAddress, + }); + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); + void advancer.handleTransactionEvent(evidence); + await eventLoopIteration(); + + // advancer does not post a tx status; settler will Forward and + // communicate Forwarded/ForwardFailed status' + t.throws(() => storage.getDeserialized(`fun.txns.${evidence.txHash}`), { + message: /no data at path fun.txns.0x/, + }); + + t.deepEqual(inspectLogs(), [ + ['decoded EUD: dydx183dejcnmkka5dzcu9xw6mywq0p2m5peks28men'], + ['⚠️ minted before Observed'], + ]); +}); diff --git a/packages/fast-usdc/test/exos/settler.test.ts b/packages/fast-usdc/test/exos/settler.test.ts index f886e419050..8545d089441 100644 --- a/packages/fast-usdc/test/exos/settler.test.ts +++ b/packages/fast-usdc/test/exos/settler.test.ts @@ -164,6 +164,22 @@ const makeTestContext = async t => { return cctpTxEvidence; }, + /** + * mint early path. caller must simulate tap before calling + * @param evidence + */ + observeLate: (evidence?: CctpTxEvidence) => { + const cctpTxEvidence = makeEvidence(evidence); + const { destination, forwardingAddress, fullAmount, txHash } = + makeNotifyInfo(cctpTxEvidence); + notifyFacet.forwardIfMinted( + destination, + forwardingAddress, + fullAmount, + txHash, + ); + return cctpTxEvidence; + }, }); return simulate; }; @@ -372,6 +388,77 @@ test('slow path: forward to EUD; remove pending tx', async t => { t.is(storage.data.get(`fun.txns.${cctpTxEvidence.txHash}`), undefined); }); +test('Settlement for unknown transaction (minted early)', async t => { + const { + common: { + brands: { usdc }, + }, + makeSettler, + defaultSettlerParams, + repayer, + accounts, + peekCalls, + inspectLogs, + makeSimulate, + storage, + } = t.context; + + const settler = makeSettler({ + repayer, + settlementAccount: accounts.settlement.account, + ...defaultSettlerParams, + }); + const simulate = makeSimulate(settler.notify); + + t.log('Simulate incoming IBC settlement'); + void settler.tap.receiveUpcall(MockVTransferEvents.AGORIC_PLUS_OSMO()); + await eventLoopIteration(); + + t.log('Nothing was transferred'); + t.deepEqual(peekCalls(), []); + t.deepEqual(accounts.settlement.callLog, []); + const tapLogs = inspectLogs(); + t.like(tapLogs, [ + ['config', { sourceChannel: 'channel-21' }], + ['upcall event'], + ['dequeued', undefined], + ['⚠️ tap: minted before observed'], + ]); + + t.log('Oracle operators eventually report...'); + const evidence = simulate.observeLate(); + t.deepEqual(inspectLogs().slice(tapLogs.length - 1), [ + [ + 'matched minted early key, initiating forward', + 'noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelqkd', + 150000000n, + ], + ]); + await eventLoopIteration(); + t.like(accounts.settlement.callLog, [ + [ + 'transfer', + { + value: 'osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + }, + usdc.units(150), + { + forwardOpts: { + intermediateRecipient: { + value: 'noble1test', + }, + }, + }, + ], + ]); + accounts.settlement.transferVResolver.resolve(undefined); + await eventLoopIteration(); + t.deepEqual(storage.getDeserialized(`fun.txns.${evidence.txHash}`), [ + /// TODO with no observed / evidence, does this break reporting reqs? + { status: 'FORWARDED' }, + ]); +}); + test('Settlement for Advancing transaction (advance succeeds)', async t => { const { accounts, diff --git a/packages/fast-usdc/test/fast-usdc.contract.test.ts b/packages/fast-usdc/test/fast-usdc.contract.test.ts index b86ff73f935..6e339ee0cfa 100644 --- a/packages/fast-usdc/test/fast-usdc.contract.test.ts +++ b/packages/fast-usdc/test/fast-usdc.contract.test.ts @@ -363,13 +363,17 @@ const makeLP = async ( const makeEVM = (template = MockCctpTxEvidences.AGORIC_PLUS_OSMO()) => { let nonce = 0; - const makeTx = (amount: bigint, recipientAddress: string): CctpTxEvidence => { + const makeTx = ( + amount: bigint, + recipientAddress: string, + nonceOverride?: number, + ): CctpTxEvidence => { nonce += 1; const tx: CctpTxEvidence = harden({ ...template, - txHash: `0x00000${nonce}`, - blockNumber: template.blockNumber + BigInt(nonce), + txHash: `0x00000${nonceOverride || nonce}`, + blockNumber: template.blockNumber + BigInt(nonceOverride || nonce), tx: { ...template.tx, amount }, // KLUDGE: CCTP doesn't know about aux; it would be added by OCW aux: { ...template.aux, recipientAddress }, @@ -408,6 +412,7 @@ const makeCustomer = ( t: ExecutionContext, amount: bigint, EUD: string, + nonceOverride?: number, ) => { const { storage } = t.context.common.bootstrap; const accountsData = storage.data.get('fun'); @@ -417,7 +422,7 @@ const makeCustomer = ( const recipientAddress = encodeAddressHook(settlementAccount, { EUD }); // KLUDGE: UI would ask noble for a forwardingAddress // "cctp" here has some noble stuff mixed in. - const tx = cctp.makeTx(amount, recipientAddress); + const tx = cctp.makeTx(amount, recipientAddress, nonceOverride); t.log(who, 'signs CCTP for', amount, 'uusdc w/EUD:', EUD); txPublisher.publish(tx); sent.push(tx); @@ -781,11 +786,6 @@ test.serial('C20 - Contract MUST function with an empty pool', async t => { await transmitTransferAck(); // ack IBC transfer for forward }); -// advancedEarly stuff -test.todo( - 'C12 - Contract MUST only pay back the Pool only if they started the advance before USDC is minted', -); - test.todo('C18 - forward - MUST log and alert these incidents'); test.serial('Settlement for unknown transaction (operator down)', async t => { @@ -794,10 +794,13 @@ test.serial('Settlement for unknown transaction (operator down)', async t => { bridges: { snapshot, since }, evm: { cctp, txPub }, common: { + bootstrap: { storage }, commonPrivateArgs: { feeConfig }, + mocks: { transferBridge }, utils: { transmitTransferAck }, }, mint, + addresses, } = t.context; const operators = await sync.ocw.promise; @@ -808,7 +811,9 @@ test.serial('Settlement for unknown transaction (operator down)', async t => { const opDown = makeCustomer('Otto', cctp, txPub.publisher, feeConfig); const bridgePos = snapshot(); - const sent = await opDown.sendFast(t, 20_000_000n, 'osmo12345'); + const EUD = 'osmo10tt0'; + const mintAmt = 5_000_000n; + const sent = await opDown.sendFast(t, mintAmt, EUD); await mint(sent); const bridgeTraffic = since(bridgePos); @@ -816,21 +821,71 @@ test.serial('Settlement for unknown transaction (operator down)', async t => { bridgeTraffic.bank, [ { - amount: '20000000', + amount: String(mintAmt), sender: 'faucet', type: 'VBANK_GRAB', }, { - amount: '20000000', + amount: String(mintAmt), recipient: 'agoric1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc09z0g', type: 'VBANK_GIVE', }, ], '20 USDC arrive at the settlement account', ); - t.deepEqual(bridgeTraffic.local, [], 'no IBC transfers'); await transmitTransferAck(); + t.deepEqual(bridgeTraffic.local, [], 'no IBC transfers'); + + // activate oracles and submit evidence; expect Settler to forward (slow path) + // 'C12 - Contract MUST only pay back the Pool (fees) only if they started the advance before USDC is minted', + operators[0].setActive(true); + operators[1].setActive(true); + // set the 3rd operator to inactive so it doesn't report a 2nd time + operators[2].setActive(false); + + // compute nonce from initial report so a new txId is not generated by `sendFast` helper + const nonce = Number(sent.txHash.slice(2)); + await opDown.sendFast(t, mintAmt, EUD, nonce); + + const [outgoingForward] = since(bridgePos).local; + t.like(outgoingForward, { + type: 'VLOCALCHAIN_EXECUTE_TX', + address: addresses.settlementAccount, + messages: [ + { + '@type': '/ibc.applications.transfer.v1.MsgTransfer', + }, + ], + }); + const [outgoingForwardMessage] = outgoingForward.messages; + t.is( + outgoingForwardMessage.token.amount, + String(sent.tx.amount), + 'full amount is transferred via `.forward()`', + ); + + const forwardInfo = JSON.parse(outgoingForwardMessage.memo).forward; + t.is(forwardInfo.receiver, EUD, 'receiver is osmo10tt0'); + + // in lieu of transmitTransferAck so we can set a nonce that matches our initial Advance + await E(transferBridge).fromBridge( + buildVTransferEvent({ + receiver: outgoingForwardMessage.receiver, + sender: outgoingForwardMessage.sender, + target: outgoingForwardMessage.sender, + sourceChannel: outgoingForwardMessage.sourceChannel, + sequence: BigInt(nonce), + denom: outgoingForwardMessage.token.denom, + amount: BigInt(outgoingForwardMessage.token.amount), + }), + ); + await eventLoopIteration(); + + t.deepEqual(storage.getDeserialized(`fun.txns.${sent.txHash}`), [ + // { evidence: sent, status: 'OBSERVED' }, // no OBSERVED state recorded + { status: 'FORWARDED' }, + ]); }); test.serial('mint received why ADVANCING', async t => {