diff --git a/packages/fast-usdc/src/exos/advancer.js b/packages/fast-usdc/src/exos/advancer.js index a5669d30588..0878598ad1a 100644 --- a/packages/fast-usdc/src/exos/advancer.js +++ b/packages/fast-usdc/src/exos/advancer.js @@ -338,7 +338,6 @@ export const prepareAdvancerKit = ( e, ); } - tmpReturnSeat.exit(); }, /** * @param {Error} error 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/src/utils/store.js b/packages/fast-usdc/src/utils/store.js new file mode 100644 index 00000000000..58e1087a1ec --- /dev/null +++ b/packages/fast-usdc/src/utils/store.js @@ -0,0 +1,119 @@ +import { Fail } from '@endo/errors'; + +/** + * @import {Key} from '@endo/patterns'; + */ + +// TODO provide something like this in a more common place, perhaps as a BagStore +/** + * Creates a bag (multi-set) API that wraps a MapStore where values are counts. + * + * @template {Key} K + * @param {MapStore} mapStore + */ +export const asMultiset = mapStore => + harden({ + /** + * Add an item to the bag, incrementing its count. + * + * @param {K} item The item to add + * @param {number} [count] How many to add (defaults to 1) + */ + add: (item, count = 1) => { + if (count <= 0) { + throw Fail`Cannot add a non-positive count ${count} to bag`; + } + + if (mapStore.has(item)) { + const currentCount = mapStore.get(item); + mapStore.set(item, currentCount + count); + } else { + mapStore.init(item, count); + } + }, + + /** + * Remove an item from the bag, decrementing its count. If count reaches + * zero, the item is removed completely. + * + * @param {K} item The item to remove + * @param {number} [count] How many to remove (defaults to 1) + * @returns {boolean} Whether the removal was successful + * @throws {Error} If trying to remove more items than exist + */ + remove: (item, count = 1) => { + if (count <= 0) { + throw Fail`Cannot remove a non-positive count ${count} from bag`; + } + + if (!mapStore.has(item)) { + return false; + } + + const currentCount = mapStore.get(item); + if (currentCount < count) { + throw Fail`Cannot remove ${count} of ${item} from bag; only ${currentCount} exist`; + } + + if (currentCount === count) { + mapStore.delete(item); + } else { + mapStore.set(item, currentCount - count); + } + return true; + }, + + /** + * Get the count of an item in the bag. + * + * @param {K} item The item to check + * @returns {number} The count (0 if not present) + */ + count: item => { + return mapStore.has(item) ? mapStore.get(item) : 0; + }, + + /** + * Check if the bag contains at least one of the item. + * + * @param {K} item The item to check + * @returns {boolean} Whether the item is in the bag + */ + has: item => { + return mapStore.has(item); + }, + + /** + * Get all unique items in the bag. + * + * @returns {Iterable} Iterable of unique items + */ + keys: () => { + return mapStore.keys(); + }, + + /** + * Get all entries (item, count) in the bag. + * + * @returns {Iterable<[K, number]>} Iterable of [item, count] pairs + */ + entries: () => { + return mapStore.entries(); + }, + + /** + * Get the total number of unique items in the bag. + * + * @returns {number} Number of unique items + */ + size: () => { + return mapStore.getSize(); + }, + + /** + * Remove all items from the bag. + */ + clear: () => { + mapStore.clear(); + }, + }); 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, diff --git a/packages/fast-usdc/test/utils/store.test.js b/packages/fast-usdc/test/utils/store.test.js new file mode 100644 index 00000000000..945edd320c0 --- /dev/null +++ b/packages/fast-usdc/test/utils/store.test.js @@ -0,0 +1,121 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { makeScalarMapStore } from '@agoric/store'; +import { asMultiset } from '../../src/utils/store.js'; + +test('add and get', t => { + const mapStore = makeScalarMapStore(); + const multiset = asMultiset(mapStore); + + // Add items + multiset.add('apple'); + multiset.add('banana', 3); + + // Check counts + t.is(mapStore.get('apple'), 1); + t.is(mapStore.get('banana'), 3); +}); + +test('has', t => { + const mapStore = makeScalarMapStore(); + const multiset = asMultiset(mapStore); + + multiset.add('apple'); + + t.true(multiset.has('apple')); + t.false(multiset.has('banana')); +}); + +test('keys and entries', t => { + const mapStore = makeScalarMapStore(); + const multiset = asMultiset(mapStore); + + multiset.add('apple', 2); + multiset.add('banana', 3); + + // Test keys + const keys = [...multiset.keys()]; + t.deepEqual(keys.sort(), ['apple', 'banana']); + + // Test entries + const entries = [...multiset.entries()]; + t.deepEqual( + entries.sort((a, b) => a[0].localeCompare(b[0])), + [ + ['apple', 2], + ['banana', 3], + ], + ); +}); + +test('clear', t => { + const mapStore = makeScalarMapStore(); + const multiset = asMultiset(mapStore); + + multiset.add('apple'); + multiset.add('banana'); + + multiset.clear(); + + t.false(multiset.has('apple')); + t.false(multiset.has('banana')); + t.is([...mapStore.keys()].length, 0); +}); + +test('add with invalid count', t => { + const mapStore = makeScalarMapStore(); + const multiset = asMultiset(mapStore); + + // Should throw when adding with count <= 0 + t.throws(() => multiset.add('apple', 0), { + message: /Cannot add a non-positive count/, + }); + t.throws(() => multiset.add('apple', -1), { + message: /Cannot add a non-positive count/, + }); +}); + +test('add to existing item', t => { + const mapStore = makeScalarMapStore(); + const multiset = asMultiset(mapStore); + + multiset.add('apple', 2); + multiset.add('apple', 3); + + // Should accumulate counts + t.is(mapStore.get('apple'), 5); +}); + +test('remove', t => { + const mapStore = makeScalarMapStore(); + const multiset = asMultiset(mapStore); + + multiset.add('apple', 2); + multiset.add('apple', 3); + + multiset.remove('apple', 4); + t.is(multiset.count('apple'), 1); + t.is(multiset.remove('apple'), true); + t.is(multiset.count('apple'), 0); + + // not successful + t.is(multiset.remove('apple'), false); +}); + +test('remove with excessive count should throw', t => { + const mapStore = makeScalarMapStore(); + const multiset = asMultiset(mapStore); + + multiset.add('apple', 3); + t.is(multiset.count('apple'), 3); + + t.throws(() => multiset.remove('apple', 5), { + message: /Cannot remove 5 of "apple" from bag; only 3 exist/, + }); + + t.is(multiset.count('apple'), 3, 'original count remains unchanged'); + + // removing exactly as many as exist (should not throw) + t.notThrows(() => multiset.remove('apple', 3)); + t.is(multiset.count('apple'), 0); + t.false(multiset.has('apple')); +}); diff --git a/packages/store/src/stores/store-utils.js b/packages/store/src/stores/store-utils.js index 692c18a9323..dc2cbc5080a 100644 --- a/packages/store/src/stores/store-utils.js +++ b/packages/store/src/stores/store-utils.js @@ -1,6 +1,6 @@ import { Fail, q } from '@endo/errors'; import { Far } from '@endo/marshal'; -import { M, matches } from '@endo/patterns'; +import { isCopyMap, isCopySet } from '@endo/patterns'; /** * @import {RankCompare} from '@endo/marshal'; @@ -9,29 +9,7 @@ import { M, matches } from '@endo/patterns'; * @import {Key} from '@endo/patterns'; */ -// TODO: Undate `@endo/patterns` to export the original, and delete the -// reimplementation here. -/** - * Should behave identically to the one in `@endo/patterns`, but reimplemented - * for now because `@endo/patterns` forgot to export this one. This one is - * simple enough that I prefer a reimplementation to a deep import. - * - * @param {unknown} s - * @returns {s is CopySet} - */ -export const isCopySet = s => matches(s, M.set()); - -// TODO: Undate `@endo/patterns` to export the original, and delete the -// reimplementation here. -/** - * Should behave identically to the one in `@endo/patterns`, but reimplemented - * for now because `@endo/patterns` forgot to export this one. This one is - * simple enough that I prefer a reimplementation to a deep import. - * - * @param {unknown} m - * @returns {m is CopyMap} - */ -export const isCopyMap = m => matches(m, M.map()); +export { isCopyMap, isCopySet }; /** * @template {Key} K