Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: minted early tracking #11066

Merged
merged 4 commits into from
Mar 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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`;
}
Comment on lines +23 to +25
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably should check that count is a Nat ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm using number and Nat is bigint

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, but we should still enforce that this in an integer, not any float.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually it's not just floats, but any NaN or non number values that will cause unexpected value for current count.


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
Loading