Skip to content

Commit

Permalink
fix: minted early tracking (#11066)
Browse files Browse the repository at this point in the history
## Description
Ensures the `mintedEarly` store can track multiple settlements for the same amount from the same address.

### Security Considerations
Ensures state diagram invariants are upheld.

### Scaling Considerations
Same considerations as #10729 - the mapStore could grow large if an attacker spams the settlementAccount with uusdc. In these changes we ensure to delete map store entries when the count goes to 0.

### Documentation Considerations
Changes should be clear to maintainers.

### Testing Considerations
Adds a new test for the scenario not originally considered.

### Upgrade Considerations
The `mintedEarly` kind is a Remotable, so changing from a `SetStore` to a `MapStore` does not seem to affect upgradability.

These change should be included in the FUSDC GTM CE.
  • Loading branch information
mergify[bot] authored Mar 1, 2025
2 parents 36f6b6e + 5597958 commit 92714b3
Show file tree
Hide file tree
Showing 6 changed files with 373 additions and 31 deletions.
1 change: 0 additions & 1 deletion packages/fast-usdc/src/exos/advancer.js
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,6 @@ export const prepareAdvancerKit = (
e,
);
}
tmpReturnSeat.exit();
},
/**
* @param {Error} error
Expand Down
24 changes: 18 additions & 6 deletions packages/fast-usdc/src/exos/settler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
}),
Expand All @@ -174,8 +176,8 @@ export const prepareSettler = (
intermediateRecipient: config.intermediateRecipient,
/** @type {HostInterface<TargetRegistration>|undefined} */
registration: undefined,
/** @type {SetStore<ReturnType<typeof makeMintedEarlyKey>>} */
mintedEarly: zone.detached().setStore('mintedEarly'),
/** @type {MapStore<ReturnType<typeof makeMintedEarlyKey>, number>} */
mintedEarly: zone.detached().mapStore('mintedEarly'),
};
},
{
Expand Down Expand Up @@ -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:
Expand All @@ -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);
}
},
},
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
119 changes: 119 additions & 0 deletions packages/fast-usdc/src/utils/store.js
Original file line number Diff line number Diff line change
@@ -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<K, number>} 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<K>} 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();
},
});
113 changes: 113 additions & 0 deletions packages/fast-usdc/test/exos/settler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 92714b3

Please # to comment.