diff --git a/packages/fast-usdc/src/exos/settler.js b/packages/fast-usdc/src/exos/settler.js index e7d69b4c188..2573937ec69 100644 --- a/packages/fast-usdc/src/exos/settler.js +++ b/packages/fast-usdc/src/exos/settler.js @@ -13,6 +13,7 @@ import { EvmHashShape, makeNatAmountShape, } from '../type-guards.js'; +import { asMultiset } from '../utils/store.js'; /** * @import {FungibleTokenPacketData} from '@agoric/cosmic-proto/ibc/applications/transfer/v2/packet.js'; @@ -149,6 +150,7 @@ export const prepareSettler = ( ).returns(M.boolean()), }), self: M.interface('SettlerSelfI', { + addMintedEarly: M.call(M.string(), M.nat()).returns(), disburse: M.call(EvmHashShape, M.nat()).returns(M.promise()), forward: M.call(EvmHashShape, M.nat(), M.string()).returns(), }), @@ -174,8 +176,8 @@ export const prepareSettler = ( intermediateRecipient: config.intermediateRecipient, /** @type {HostInterface|undefined} */ registration: undefined, - /** @type {SetStore>} */ - mintedEarly: zone.detached().setStore('mintedEarly'), + /** @type {MapStore, number>} */ + mintedEarly: zone.detached().mapStore('mintedEarly'), }; }, { @@ -221,7 +223,7 @@ export const prepareSettler = ( case PendingTxStatus.Advancing: log('⚠️ tap: minted while advancing', nfa, amount); - this.state.mintedEarly.add(makeMintedEarlyKey(nfa, amount)); + self.addMintedEarly(nfa, amount); return; case PendingTxStatus.Observed: @@ -234,7 +236,7 @@ export const prepareSettler = ( 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)); + self.addMintedEarly(nfa, amount); } }, }, @@ -256,7 +258,7 @@ export const prepareSettler = ( const { value: fullValue } = fullAmount; const key = makeMintedEarlyKey(forwardingAddress, fullValue); if (mintedEarly.has(key)) { - mintedEarly.delete(key); + asMultiset(mintedEarly).remove(key); statusManager.advanceOutcomeForMintedEarly(txHash, success); if (success) { void this.facets.self.disburse(txHash, fullValue); @@ -290,7 +292,7 @@ export const prepareSettler = ( forwardingAddress, amount, ); - mintedEarly.delete(key); + asMultiset(mintedEarly).remove(key); statusManager.advanceOutcomeForUnknownMint(evidence); void this.facets.self.forward(txHash, amount, destination.value); return true; @@ -299,6 +301,16 @@ export const prepareSettler = ( }, }, self: { + /** + * Helper function to track a minted-early transaction by incrementing or initializing its counter + * @param {NobleAddress} address + * @param {NatValue} amount + */ + addMintedEarly(address, amount) { + const key = makeMintedEarlyKey(address, amount); + const { mintedEarly } = this.state; + asMultiset(mintedEarly).add(key); + }, /** * @param {EvmHash} txHash * @param {NatValue} fullValue diff --git a/packages/fast-usdc/test/exos/settler.test.ts b/packages/fast-usdc/test/exos/settler.test.ts index 61ea505df15..089468100f9 100644 --- a/packages/fast-usdc/test/exos/settler.test.ts +++ b/packages/fast-usdc/test/exos/settler.test.ts @@ -546,6 +546,119 @@ test('Settlement for unknown transaction (minted early)', async t => { ]); }); +test('Multiple minted early transactions with same address and amount', 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.notifier); + + t.log('Simulate first incoming IBC settlement'); + void settler.tap.receiveUpcall(MockVTransferEvents.AGORIC_PLUS_OSMO()); + await eventLoopIteration(); + + t.log('Simulate second incoming IBC settlement with same address and amount'); + void settler.tap.receiveUpcall(MockVTransferEvents.AGORIC_PLUS_OSMO()); + await eventLoopIteration(); + + t.log('Nothing transferred yet - both transactions are minted early'); + t.deepEqual(peekCalls(), []); + t.deepEqual(accounts.settlement.callLog, []); + const tapLogs = inspectLogs(); + + // Should show two instances of "minted before observed" + const mintedBeforeObservedLogs = tapLogs + .flat() + .filter( + log => typeof log === 'string' && log.includes('minted before observed'), + ); + t.is( + mintedBeforeObservedLogs.length, + 2, + 'Should have two "minted before observed" log entries', + ); + + t.log('Oracle operators report first transaction...'); + const evidence1 = simulate.observeLate( + MockCctpTxEvidences.AGORIC_PLUS_OSMO(), + ); + await eventLoopIteration(); + + t.log('First transfer should complete'); + t.is( + accounts.settlement.callLog.length, + 1, + 'First transfer should be initiated', + ); + accounts.settlement.transferVResolver.resolve(undefined); + await eventLoopIteration(); + t.deepEqual(storage.getDeserialized(`fun.txns.${evidence1.txHash}`), [ + { evidence: evidence1, status: 'OBSERVED' }, + { status: 'FORWARDED' }, + ]); + + t.log( + 'Oracle operators report second transaction with same address/amount...', + ); + const evidence2 = simulate.observeLate({ + ...MockCctpTxEvidences.AGORIC_PLUS_OSMO(), + txHash: + '0x0000000000000000000000000000000000000000000000000000000000000000', + }); + await eventLoopIteration(); + + t.log('Second transfer should also complete'); + t.is( + accounts.settlement.callLog.length, + 2, + 'Second transfer should be initiated', + ); + accounts.settlement.transferVResolver.resolve(undefined); + await eventLoopIteration(); + t.deepEqual(storage.getDeserialized(`fun.txns.${evidence2.txHash}`), [ + { evidence: evidence2, status: 'OBSERVED' }, + { status: 'FORWARDED' }, + ]); + + // Simulate a third transaction and verify no more are tracked as minted early + simulate.observe({ + ...MockCctpTxEvidences.AGORIC_PLUS_OSMO(), + txHash: + '0x0000000000000000000000000000000000000000000000000000000000000001', + }); + const foundMore = inspectLogs() + .flat() + .filter( + log => + typeof log === 'string' && log.includes('matched minted early key'), + ); + t.is( + foundMore.length, + 2, + 'Should not find any more minted early transactions', + ); + t.is( + accounts.settlement.callLog.length, + 2, + 'No additional transfers should be initiated', + ); +}); + test('Settlement for Advancing transaction (advance succeeds)', async t => { const { accounts,